Compare commits

..
Author SHA1 Message Date
jheapsandClaude Opus 5.5 798e8c2e57 Socialism: compare co-op taxes honestly with partnerships and S corporations, explain why members can't take the 20% pass-through deduction on the same ownership theory as the payroll-tax argument, and count the small-business stock exclusion in the founder gap
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-27 14:54:44 -06:00
jheapsandClaude Opus 5.5 fd04ea3886 Socialism: work out how the return on members' accounts is taxed, pay the two-point premium every year so it isn't a share of profit to capital, and credit returns after the co-op's tax so all-cash members don't pay it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-27 14:24:57 -06:00
jheapsandClaude Opus 5.5 89f73a58e4 Socialism: test the "lower-paid work" rule on 2024 federal wage data, add a national check so consultancies in rich counties don't qualify, hold half the set-aside for the lowest-paid industries, and close code, county and timing games
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-27 11:27:35 -06:00
jheapsandClaude Opus 5.5 afb5330e08 Socialism: give the bank a working legal basis to ask loan applicants' race and sex, since the federal small-business data rule won't reach it, with a separate custodian, deletion and small-number limits
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-27 10:53:01 -06:00
jheapsandClaude Opus 5.5 f8c1a1e813 Socialism: stop balancing the founding lottery on race and gender, set the rule that race and sex measure results but never decide who gets anything, and apply it to loan data and layoff checks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-27 10:23:30 -06:00
jheapsandClaude Opus 5.5 3b7bde0568 Socialism: sort out what a worker is owed in notice pay and severance when a co-op closes, have the insurance pool pay notice pay after unpaid wages with limits against padding, and reprice wage cover to match
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-27 09:56:08 -06:00
jheapsandClaude Opus 5.5 eaac398c0f Socialism: say plainly when co-op members and officers can personally owe unpaid wages (federal law, New York, Massachusetts, California), stop ordinary work after a missed payroll, and narrow the model state law's shield so workers who aren't members keep their claims
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-27 09:22:41 -06:00
jheapsandClaude Opus 5.5 a983b7b6a8 Socialism: price the savings insurance pool from Census closure rates by firm age, write out the formula that cuts limits when the reserve falls behind, and say what a young pool would pay in a bad recession
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-27 05:58:04 -06:00
jheapsandClaude Opus 5.5 9dd06ff181 Socialism: make whoever carries on a failed co-op's business answer for people its products hurt later, honor warranties and household deposits, and put members' plans to keep a co-op through a market test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-27 05:25:01 -06:00
jheapsandClaude Opus 5.5 12905d5db1 Socialism: stop leases, licenses and sold invoices from carrying a co-op's business around the lock: essential leases must allow a co-op successor, sale-leasebacks count as sales, and cash advances are limited
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-27 04:51:57 -06:00
jheapsandClaude Opus 5.5 acaf7f0adc Socialism: keep checking insider deals after the bank loan is repaid, as part of the lock, on the model of the federal rules for charity insiders
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-27 04:19:52 -06:00
jheapsandClaude Opus 5.5 2ac3f59695 Socialism: size what the lock can cost creditors who never chose to lend, say who's covered, keep the appraisal honest, and have the co-op that buys the business pay it back
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-27 00:51:18 -06:00
jheapsandClaude Opus 5.5 d96cec00c7 Socialism: say how a workers' bid for a failed co-op gets paid for, cap the bank's part at the break-up value so it can't hide its losses, and set the insurance pool's repayment terms
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-27 00:20:24 -06:00
jheapsandClaude Opus 5.5 cc343b7d0c Socialism: justify the long-run rules' numbers: severance only for people let go, equipment measured at original cost, buyouts judged on the seller's book values, and cheaper second opinions for small co-ops
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-26 23:52:48 -06:00
jheapsandClaude Opus 5.5 f43f418ff3 Socialism: set the bonus limits and the dismissal panel: 10% of pay in total, 15% for anyone, gain sharing even without a profit, and four of five panelists drawn by lot
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-26 23:20:16 -06:00
jheapsandClaude Opus 5.5 ab39dd4a27 Socialism: set the care-work supplement at a first-draft $3 an hour with no per-worker fade-out, spell out its tax treatment, and say who can get it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-26 20:20:02 -06:00
jheapsandClaude Opus 5.5 a90ce03297 Socialism: set the spin-off numbers: a 15% stake cap counting every exposure, a real consequence for captive spin-offs, and an honest power estimate for the growth comparison
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-26 19:49:50 -06:00
jheapsandClaude Opus 5.5 05c8b21632 Socialism: compare the labor-market effect with wage boards and unions, and say a wage board is the faster way to raise pay
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-26 19:20:50 -06:00
jheapsandClaude Opus 5.5 cf0d45684e Socialism: test co-ops' hiring with paired fake applications, as a group, and make discrimination a loan breach at any size
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-26 18:47:58 -06:00
jheapsandClaude Opus 5.5 ce844851ca Socialism: work out the local labor-market test's numbers, and say that on public data it fails its own rule
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-26 18:17:19 -06:00
jheapsandClaude Opus 5.5 80501e21fd Socialism: say what members can deduct when a failed co-op wipes out their accounts, and ask for a general refund of the tax paid
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-26 14:47:03 -06:00
jheapsandClaude Opus 5.5 36afaac20a Socialism: give every co-op worker an ordinary retirement plan, with a flat co-op contribution and the new federal match
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-26 14:18:05 -06:00
jheapsandClaude Opus 5.5 774ea901e5 Socialism: weigh the pension-law cases on accounts like these, and pause untaxed credits until the Labor Department answers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-26 13:48:50 -06:00
jheapsandClaude Opus 5.5 e5bc9f2ba2 Socialism: say how lower-income members' untaxed credits follow the deferred-pay rules
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-26 13:16:00 -06:00
jheapsandClaude Opus 5.5 f8c6b524ac Socialism: say where securities law reaches members' accounts and outside investors
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-26 09:47:23 -06:00
jheapsandClaude Opus 5.5 bfca27ac66 Socialism: say how salaried staff share a cut, and keep the legal minimum wage out of workers' bids
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-26 09:17:25 -06:00
jheapsandClaude Opus 5.5 22de1f885f Socialism: recompute the founder and member examples under the current rules, and pay the founding share as pay
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-26 08:47:39 -06:00
jheapsandClaude Opus 5.5 fb0eede6a6 Socialism: say how a co-op cuts back when work runs short
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-26 08:14:14 -06:00
jheapsandClaude Opus 5.5 c41c88f979 Socialism: say how the lock handles companies a co-op controls or buys
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-26 04:44:45 -06:00
jheapsandClaude Opus 5.5 ffe6ff2963 Socialism: set the going rate for people who work remotely from another area
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-26 04:14:18 -06:00
jheapsandClaude Opus 5.5 d87c8d3633 Socialism: count the old capital gains break on buyouts against the buyout track
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-26 03:46:31 -06:00
jheapsandClaude Opus 5.5 7cda37be0d Socialism: give the board the last word on loans, and make every use of it public
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-26 03:14:20 -06:00
jheapsandClaude Opus 5.5 9a5648b638 Socialism: draft the model state law that closes the lock's exits under state law
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-26 00:14:42 -06:00
jheapsandClaude Opus 5.5 7973cb2e0c Socialism: say who can take the bank to court, what a judge can order, and where that runs out
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-25 23:43:11 -06:00
jheapsandClaude Opus 5.5 7ef29c8b5a Socialism: draw the line for when turned-away applicants make the verdict "not tested"
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-25 23:14:01 -06:00
jheapsandClaude Opus 5.5 45384a0709 Socialism: set the cost ceiling for help before the first loan, and count every cost against it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-25 22:41:44 -06:00
jheapsandClaude Opus 5.5 16d42cc0ef Socialism: count what buyout help really costs, and make sellers pay for their own checks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-25 22:12:25 -06:00
jheapsandClaude Opus 5.5 6dbb637c3b Socialism: update the buyout page for the SBA's October 2026 rules, and admit the gap they leave is narrow
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-25 18:41:01 -06:00
jheapsandClaude Opus 5.5 c1570f0680 Socialism: say what the rules cost a small co-op, and scale the checks to size
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-25 18:15:07 -06:00
jheapsandClaude Opus 5.5 8ec9cc0976 Socialism: hold buyout co-lending to real practice, and use state and rural programs without letting them hide risk
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-25 17:41:59 -06:00
jheapsandClaude Opus 5.5 2747aae0d1 Socialism: firm up the buyout numbers against the SBA's own rules
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-25 17:10:10 -06:00
jheapsandClaude Opus 5.5 2b31f5c219 Socialism: spell out what worker buyouts are compared against, and stop the tests flattering them
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-25 13:42:16 -06:00
jheapsandClaude Opus 5.5 a196614fb4 Socialism: say how a care co-op meets Medicaid's 80% home care pay rule
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-25 13:12:40 -06:00
jheapsandClaude Opus 5.5 adf8046773 Socialism: keep membership from costing care workers their public benefits
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-25 12:40:54 -06:00
jheapsandClaude Opus 5.5 855f9e099d Socialism: say plainly whether a member's profit share is pay, law by law
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-25 12:07:07 -06:00
jheaps 139e2b831c Merge pull request 'Socialism: make the lock hold, and close its back doors' (#125) from feature/socialism into master
Build and Deploy / build-deploy (push) Successful in 1m25s
Reviewed-on: #125
2026-09-25 11:08:48 -06:00
jheaps 96d2dbe5ad Merge branch 'master' into feature/socialism 2026-09-25 11:08:31 -06:00
jheapsandClaude Opus 5.5 2c269d075d Socialism: pay capital accounts on a fixed schedule so they stay clear of pension law
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-25 07:42:01 -06:00
jheapsandClaude Opus 5.5 7baa6e9810 Socialism: say where labor law leaves worker-owners out and how the co-op fills the gaps
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-25 07:08:43 -06:00
jheapsandClaude Opus 5.5 f76b766d0e Socialism: measure founders on both sides so a pilot result says how much was down to who applied
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-25 03:08:51 -06:00
jheapsandClaude Opus 5.5 aad548cd07 Socialism: name the data that decides which low-paid care work qualifies for public help
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-25 02:38:30 -06:00
jheapsandClaude Opus 5.5 f4271831f4 Socialism: price the savings insurance pool from real failure data and size it for a long recession
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-25 02:03:24 -06:00
jheapsandClaude Opus 5.5 1e82052324 Socialism: fit profit sharing to co-op tax law so members are taxed once
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-24 21:44:16 -06:00
jheapsandClaude Opus 5.5 a9efaa2725 Socialism: keep the co-op bank from drifting toward only the safest co-ops
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-24 21:07:30 -06:00
jheapsandClaude Opus 5.5 19bba58f7c Socialism: draft the bankruptcy amendment that keeps a failed co-op a co-op
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-24 17:39:10 -06:00
jheapsandClaude Opus 5.5 4006764a23 Socialism: stop high pay and worn-out equipment from draining the reserve, and check big bets
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-24 17:09:11 -06:00
jheapsandClaude Opus 5.5 a0b16e7d48 Socialism: work through the 1/N problem, with team bonuses and a fair dismissal process
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-24 16:33:57 -06:00
jheapsandClaude Opus 5.5 059299db74 Socialism: let a formula, not lobbyists, decide which low-paid work gets public help
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-24 15:59:16 -06:00
jheapsandClaude Opus 5.5 20d0ee8910 Socialism: let co-ops grow by starting new co-ops, with rules against misuse
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-24 11:35:54 -06:00
jheapsandClaude Opus 5.5 e6befaff5e Socialism: work through scale after a pass, and a local test of the labor-market effect
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-24 11:05:38 -06:00
jheapsandClaude Opus 5.5 8305c72843 Socialism: test help before the first loan, and ask founders for a stake
Add before-launch.md: a founding lottery testing whether serious
pre-launch help (a founder's stipend, a feasibility study, legal setup)
creates more co-ops that last, measured independently of the bank, and
a 5% founder stake payable in verified unpaid work. Fund it from the
loan budget, and correct the pilot's growth arithmetic.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-24 06:31:46 -06:00
jheapsandClaude Opus 5.5 1f297c244d Socialism: decide pay in the open, with the lower-paid protected
Add pay.md: pay bands open to every worker, a ratio each co-op must
choose and measure honestly, a double majority for high pay and for
raises to jobs most members hold, and no one voting alone on their own
pay. Stop the reserve rule from charging co-ops for raising the bottom,
and close its gaps for perks, non-members and related companies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-24 05:59:02 -06:00
jheapsandClaude Opus 5.5 a77d985135 Socialism: make membership work where people don't stay long
Add high-turnover.md: in home care, a 12-month probation and the
three-quarters rule could fail a co-op doing everything right. Make
membership automatic after a short probation, take the buy-in from
profit shares rather than pay, count agency hours and push-outs, and
phase the rule in. Align the lock, the pilot's safeguards and measures,
worker ownership, the index and open problems.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-24 02:03:06 -06:00
jheapsandClaude Opus 5.5 78650c8ee7 Socialism: work through Conservice as a co-op counterfactual
Add conservice.md: the company as it is (from public sources only),
the same business founded as a locked co-op, what the profit split
could mean at its scale under labeled assumptions, whether it would
have grown, and who would have received less. Link it from the
thesis, worker ownership and the index.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-24 01:33:27 -06:00
jheapsandClaude Opus 5.5 d4f12c2b47 Socialism: lend for voluntary sales to workers, on a separate track
Add buyouts.md: owners who choose to sell a healthy firm to its workers
can borrow on a separate $100M track with its own test (money plus a
pay floor), a capped independent price, a seller note that shares the
risk, a vote of the whole workforce and the lock. Restate the total
worst case as $450M and answer open problem #12.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-24 00:59:20 -06:00
jheapsandClaude Opus 5.5 037e29055a Socialism: build the bank to survive a hostile government
Add "Making It Last": how public programs get killed (Ex-Im, CDFI Fund,
GGRF, NCCB) and survive (Bank of North Dakota, electric co-ops, Farm
Credit), and rules that assume any president can replace the board
after Trump v. Slaughter. Lending standards fixed in law, a "not tested"
verdict if good applicants are turned away, no freeze from board
vacancies, honest limits on where the money sits, and federal test with
state copies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-23 21:03:11 -06:00
jheapsandClaude Opus 5.5 27dd0ccb3e Socialism: insure workers' final wages and savings, without taxpayers
Add savings-guarantee.md: a co-op-funded insurance pool covering final
unpaid wages, former members' payouts and a protected slice of current
accounts, priced for near-zero recovery by an independent actuary, with
a credit-union-style deposit, phased coverage, pro-rata payment when
short, and rules against phantom profits, early quitting and fraud
rings. Co-ops may no longer take member deposits or sell members bonds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-23 20:28:16 -06:00
jheapsandClaude Opus 5.5 4c1c81fd2f Socialism: make the lock hold, and close its back doors
Explain why a permanent lock has precedent (UK CICs, steward ownership,
US nonprofit conversions) and what it prevents (building-society
demutualisation). Enforce it in layers: an independent veto-only lock
share, articles, statute, and a backstop that sends the value to the
federation fund if the lock is ever broken. Treat piecemeal asset sales,
hire-backs and subsidiaries as sales; adapt Argentina's bankruptcy rule,
noting it needs a Bankruptcy Code amendment; state the lock's costs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-23 19:54:25 -06:00
jheaps 7cc964380b Merge pull request 'Socialism: capital accounts, the founder problem, and the horizon problem' (#124) from feature/socialism into master
Build and Deploy / build-deploy (push) Successful in 1m15s
Reviewed-on: #124
2026-09-23 16:33:31 -06:00
jheapsandClaude Opus 5.5 3e072dbd5d Socialism: study the precedents and test whether the help matters
Add precedents.md: Mondragon's Caja Laboral, the US National Consumer
Cooperative Bank, Italy's Coopfond and Marcora Law, each with what went
wrong. Add a support lottery to the pilot (intensive help for a random
half, advisers walled off from lending, all help counted against the
bank), count distressed mergers as closures, and replace unsourced
precedent claims on the bank and argument pages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-23 16:02:27 -06:00
jheapsandClaude Opus 5.5 4e686aaf72 Socialism: answer the horizon problem with reserve rules, not share sales
Add the-long-run.md: the Furubotn-Pejovich worry, the French and
Italian evidence, the plywood "failure of success", and four rules:
the reserve takes its share of pay above the going rate, asset-sale
gains go to the reserve, qualifying development can be spread over
five years with abandonment charged to those who chose it, and
accounts earn Treasury+2 only while at risk. Update capital accounts,
worker ownership, the pilot's measures, open problems and the index.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-23 15:32:19 -06:00
jheapsandClaude Opus 5.5 a75c24fcf2 Merge unmerged socialism work back onto master
PR #123 was merged before 56c041f was pushed; bring it back in.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-23 15:22:12 -06:00
jheapsandClaude Opus 5.5 56c041f405 Socialism: member capital accounts and the founder problem in numbers
Add capital-accounts.md (profit split, cash share, formula interest,
payout rules, solvency test, savings protections, worked outcomes).
Rewrite founder-problem.md with a discounted co-op vs conventional
comparison, the risk evidence (Hall-Woodward, Hamilton), and bounded
founder tools. Add founder-related measures and safeguards to the
pilot pages, and renumber open problems.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-23 14:51:56 -06:00
jheaps f9cf2752d2 Merge pull request 'Add unlisted /socialism pages for the worker-ownership model' (#123) from feature/socialism into master
Build and Deploy / build-deploy (push) Successful in 1m21s
Reviewed-on: #123
2026-09-23 12:17:16 -06:00
jheapsandClaude Opus 5.5 54f951520a Socialism: spec the pilot in numbers, and guard it against gaming
Add "The Pilot, in Numbers": a one-time $350M, ~400-co-op pilot with
a power-calculated sample size, loan limits, a checked definition of
market rate, two pre-registered deciding measures (fair survival with
a pay and membership guardrail, and a Treasury-rate hurdle for the
loan book), a matched SBA comparison plus a lottery at the margin, a
year-12 review, a fast-track vote on success and a self-executing
wind-down on failure. Built around the FONDES and Venezuela failures.

Add "Guarding the Pilot": rules against hidden-boss shells, capital
claim drains, split borrowers, relabeled rescues, zombie survival,
sabotage, a rigged review, and political favoritism.

Link both from the index, bank, test, capital and lock pages, and add
the pilot's weak points and the federal-vs-state question to Open
Problems.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-23 10:00:03 -06:00
jheapsandClaude Opus 5.5 64852cf6b7 Socialism: rebuild on the v3 model (co-op bank and lock, no forced conversion)
Replaces the chapters with the v3 foundation: new chapters for the bank,
the lock, the founder problem, the test and intellectual property; removes
conversion, conversion-tax and valuation. Rewrites the overview to lead
with "nobody is forced" and rewords draft-history references for
first-time readers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 08:20:05 -06:00
jheapsandClaude Opus 5.5 0340c33bfb Socialism: collect the conversion tax so it stays a tax
Adds The Conversion Tax chapter. The tax is withheld in cash from each
buyout payment, the rate schedule is locked on conversion day, brackets
run on cash actually received, pre-bill gifts are added back to the
lifetime ledger, and above-market side payments to former owners count
as proceeds. It explains why taking the tax in kind was rejected
(Koontz, Horne, Tyler). It also corrects the 1950s precedent (capital
gains were capped at 25%) and states plainly how the recycled tax
lowers what workers pay. Open problems now cover proceeds vs gains,
treaty overrides and recession cyclicality.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GsSJ98117VXm9t7rBra5NX
2026-09-23 08:10:48 -06:00
jheapsandClaude Opus 5.5 b1ed1b9b13 Socialism: set conversion prices by a rule ladder, not dueling experts
Adds a Valuation chapter. It covers market price, control-size arm's-length
sales, and a formula whose industry multiples come from real acquisitions
and whose profit figures are cleaned of related-party games. Disputes stay
in court (Monongahela), but each side files a final number, costs follow
reasonableness, inflated claims draw a penalty, and workers get an
independent public valuation counsel. Conversion brackets now apply to
lifetime proceeds and look through foundations and trusts. Open problems
are updated for the anticipation discount and conversion-tax durability.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GsSJ98117VXm9t7rBra5NX
2026-09-23 05:01:56 -06:00
jheapsandClaude Opus 5.5 e8f930e9fc Add unlisted /socialism pages for the worker-ownership model
Renders the markdown documents in Content/Socialism: a landing overview at
/socialism and chapters at /socialism/{slug}, with a sectioned sidebar,
on-page table of contents, prev/next paging and cross-document links.
Pages are noindex and nothing on the site links to them. Underscore-prefixed
files (founding draft, progress log) are never served, deployed or committed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 03:10:35 -06:00
jheaps 3326557043 Merge pull request 'Hold'em: give the seat holding the dealer button its own middle back' (#121) from fix/holdem-dealer-chip-eats-its-seat into master
Build and Deploy / build-deploy (push) Successful in 1m12s
Reviewed-on: #121
2026-09-09 15:18:47 -06:00
jheapsandClaude Opus 5 edf159325b Give the seat holding the button its own middle back
On a fresh table the button is on seat 1, and seat 1 is where the first player
goes. Tapping it did nothing.

The dealer chip is a 22px disc, and a 22px disc is half the 44px a fingertip
needs, so it carried an invisible pad to catch the tap. The pad was moved twice
looking for somewhere to grow that wasn't already spoken for: outward first,
where it hung over the seat next door and gave that seat the button; then
inward, over "its own seat, which is a large target that can spare the corner".

The seat could not spare it. Measured on a 390px phone, a 72x46 puck: the pad
ran from the puck's own centre to its right edge and from its top to 3px off its
bottom, and the chip's handler calls stopPropagation. The seat holding the
button kept 52% of its face — 130 of 276 sampled points went to the chip, its
exact middle among them. Every other seat kept 97%. The button moves every hand,
so the dead puck went round the table with it.

And the chip it went to had nothing to say. Its only answer is "give the button
to this seat", and this is the seat that has it. The seat menu already knew that
and hides "Give the button" there; mid-hand the same no-op would have taken the
blinds off the felt and re-posted the same chips to announce that nothing
happened.

So the pad is gone rather than moved again — a fixed pad cannot be safe at every
puck size, and no finger reaches any chip now: the ghosts are pointer-events:
none without hover, and the one live chip is this one, which is now inert to the
pointer and still there to read. Giving the button is the long-press menu on
touch and the revealed ghost on a mouse, both unchanged. makeDealer returns
early on the seat that already has it, so the keyboard can't re-post blinds to
move the button nowhere either.

The sweep had a check for exactly this — "a seat's centre belongs to that seat",
written when the pad was reaching outward — and it ran clean through all of it,
because `seat.contains(hit)` waves through anything inside the seat and the chip
is inside the seat. The check written for this bug could not see the second half
of it. A control inside a seat is a rival for the tap now, and against the old
stylesheet it fails seven scenarios.

982 unit tests, 6 of them new; the layout sweep is 320 audits with the one
pre-existing failure (a $1,300 chip behind seat 11 on a twelve-handed 360x640
all-in).

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 15:10:55 -06:00
jheaps 6e7b047d3e Merge pull request 'Hold'em dealer: a table that reads its own showdown, and a tool that is there with no signal' (#120) from polish/holdem-game-night-ready into master
Build and Deploy / build-deploy (push) Successful in 1m14s
Reviewed-on: #120
2026-09-09 14:23:50 -06:00
jheapsandClaude Opus 5 171e878262 Give the felt back the sixth of the phone the title was holding
The header stood 120px tall on a 390x844 phone and only 46 of that was a
control. The other 74 were a 22px page title with a gold rule under it, a back
link, and a gold "Hand #1" pill — three things read once, above a screen whose
whole subject is the green table underneath. Sixteen percent of the window,
spent before the felt started.

A phone held sideways had already solved this. The landscape block clips the
title and puts everything on one row, because at 390px tall there was no other
option. Upright never got the same answer, and upright is how this tool is
held. It is 44px now, the table starts at 78px instead of 152, and the felt
went from 496px to 570 — about fifteen percent more table on the same screen.

Nothing left the page:

- The title is still the document's <h1>, clipped rather than removed, so it is
  still the heading a screen reader lands on and still what the browser tab and
  the installed app are called.
- The back link keeps a 44px box and loses only the word "Casino", which is what
  every phone does with a back link. The name a screen reader gets is on the <a>
  either way, and it contains the visible word on the sizes that still show it.
- The hand number moved to the action bar's status row, in front of the street.
  That row already says what hand you are in — the street, the pot, the player
  count — and this is the fourth fact of the same kind; a counter read after the
  fact had no business holding a line of the header all night. It costs the bar
  nothing: the five audit scenarios that report the bar's height report the same
  249, 227, 203, 227 and 227 pixels as before.

The three buttons take the width the title gave up rather than shrink-wrapping
to the right of it, which is 26px of extra target each. `flex: 1 1 0` was wrong
here — it divides the row into equal thirds and squeezes "Next Hand ⟳", 105px of
text, into 99, and these are nowrap, so what gives is the label. An `auto` basis
makes the label the floor and the spare width the bonus. On the narrowest phone
in the sweep the arrow and all three buttons come to 323px of 336.

The arrow was 32px on the first pass and the layout sweep caught it at 31x43 on
every touch viewport there is — 56 scenarios, one control. The word had been
carrying the width; with the word gone the arrow is the whole target. 44 square.

The sweep is down from five failures to one. Four of the five were seats and bet
chips colliding on a 360px Galaxy S8, and 76px more felt was all they needed.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 08:11:21 -06:00
jheapsandClaude Opus 5 0f7213ec2d Open a seat on what is on it, and put the sheet in front of the table
The seat sheet is the second-most-used surface here — every buy-in, every
rename, every correction goes through it — and it was the least finished one.
Seven things were stacked flat at one weight and all open at once. Measured on
a 390px phone it came to 594px and scrolled, and the three controls a dealer
touches on nearly every visit were 132px of that. The chip counter alone was
174px, the tallest single thing in the sheet, for a job most tables do once a
night or never; the two hole-card slots were 62px of dashed grey on every table
that never asked to be shown a card.

Cards, the contribution and the custom fields are behind a fold now, and which
of the three stand open is a question about the seat rather than a setting: a
seat holding cards opens its cards, a seat with chips counted opens its count,
a seat with a note on it opens its notes. A shut section says the answer on its
own row — the cards, the total, how many notes — so nothing already entered is
further away than it was. An empty chair is 419px and does not scroll; a seat
with everything filled in is 625 and opens all three.

The fields have the width the sheet always had. `.fieldRow input` caps at 140px,
which is right in a 320px sidebar of settings and wrong in a sheet holding a
whole phone: "Name" sat against the left edge with 203px of nothing between it
and a field a third of the row wide. Same 44px control, 300px instead of 140.

The chip counter's values are text. They were a `title` attribute, which is a
tooltip, which is nothing on the device this is used on — five identical number
boxes told apart by five coloured dots. It also left the wrapping <label> with
no text in it, so they had no accessible name either.

And the sheet has a scrim. It was the only one of the tool's seven overlays
without one, while covering the felt from 250px down and the action bar with
it — modal in everything but appearance. What stayed lit above it was the
header, with Misdeal, Next Hand and Resolve Pot live under a thumb reaching for
a name field. It dismisses on a tap and on Escape now, like the other six; a
card picker opened from inside still takes Escape first. In landscape the scrim
stops where the sheet stops, so Fold, Call and Raise keep the rail.

It is put away with visibility rather than opacity, which is the same lesson the
sheet above it already learned about transforms — and the layout sweep caught it
the same way. At `opacity: 0` a fixed, full-window box is invisible and still
entirely there: bottomFurniture() read it as furniture standing 640px tall at
the bottom of a 640px phone, in every state and on every screen, and asked
scroll-padding to reserve the whole window for it. Ninety-eight scenarios failed
on it; hidden is hidden, and the sweep is back to the five it started with.

The toast steps over the sheet the way it already steps over the action bar.
Both are fixed to the bottom of a phone, and a "Keeping the screen on" toast
was landing squarely on the seat form with its Undo and its × live above the
fields. The height is measured and published, because it changes the moment a
section is unfolded.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 07:29:03 -06:00
jheapsandClaude Opus 5 853fcedea0 Measure the text the way an eye reads it, not the way a ratio scores it
The sweep has checked WCAG AA contrast for a long time and the page passed. That
result was worth less than it looked.

WCAG 2's ratio is a luminance gap, and a luminance gap overstates readability once
one colour is near black — which is every reading on a page whose ground is #141414.
Tuning a colour until it clears 4.5:1 there lands on text that passes the audit and
still reads as a smudge across a table in a dim room. Several rules in this
stylesheet said as much in their own comments, one of them "measured 4.5:1 at
#a5a5a5 exactly, so it sits above that": the right instinct through the wrong lens.

The check also could not see half the backgrounds it scored against. It reads
background-color and takes the best contrast of any candidate ancestor, so text on
the felt was scored against the dark page behind the felt. Two plain AA failures sat
in a clean report for as long as the check has existed — the chair number on the
brown rim at 2.65:1, and the pot's own label, gold on green, at 2.66:1.

runContrastAudit.js scores with APCA and gets the background by screenshotting the
page with its text made transparent and reading the pixels, which no gradient or
shadow can fool. The first version walked the DOM and called the gold dealer button
black-on-black at Lc 0.4; that false positive is why it works this way.

Found: 65 of 92 pieces of text below their level, 54 passing AA at the same moment,
worst Lc 28. Now nothing is below Lc 60, nothing fails AA, and the worst is Lc 60.4.

Three fixes worth naming, none of them "make the grey lighter":

- A player's stack was secondary text at Lc 44, under a name in --text at a heavier
  weight. That says a dealer needs the name more than the number; during a hand the
  opposite is true. Hierarchy comes from size and weight now, which is free, rather
  than from fading, which on a dark ground is the most expensive way to say it.
- An empty seat had no background of its own, so the felt decided how readable its
  number was: Lc 28 on the rim, 30 on the green, 33 further in. It has a ground now,
  at 0.62 and not the 0.82 first written — contrast is nearly flat across that range
  and the heavier wash put four dark rectangles on the felt like holes.
- The pot's label sat on the one green in the design. The disc is deeper rather than
  the label bleached, because gold on dark green is the pairing this page is built
  on. Deepening only its outer stop moved it two points: the highlight is at 32%/28%
  and that is exactly where the label sits.

apca.js is checked against the eight published reference pairs, png.js against
round-tripped fixtures for every filter. Four fault injections caught; a fifth
turned out to be a no-op — the Paeth predictor is symmetric in its first two
arguments over all 16,777,216 byte triples, so that swap is untestable, which is
written down rather than papered over with a fixture that appears to catch it.

Still below the strict APCA level: 55, all of them 10-12px labels that would need
white type or a bigger seat puck. Reported, not chased, and said out loud in the
sweep's own notes.

Layout sweep unchanged: 5 FAILs, 14 offender lines, no regressions. 950 tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 06:40:28 -06:00
jheapsandClaude Opus 5 295d9eff4a Stop a scroll being an edit, and a stack changing without being touched
A focused <input type="number"> is a spinbutton, and a browser lets the mouse
wheel drive it. Measured on the running page at 1280x900 with the sidebar
open: focus the small blind and scroll, $5 becomes $5.50. Focus a seat's stack
and scroll, $500 becomes $501. Both go straight through to localStorage,
because these fields save on `input` like everything else here.

Nothing is announced and nothing looks wrong. The tool has invented a chip, on
a table whose whole purpose is that the chip count is right, and the gesture
that did it was a dealer reaching for another panel.

The rule is narrow on purpose. Every other way of driving a spinbutton is
deliberate -- the arrow keys, the stepper arrows, typing -- so scrolling is
the only one taken away. Verified after: the value holds, one ArrowUp still
moves it, typing still works, and the page still scrolls.

Blur rather than preventDefault. Cancelling the event stops the value moving
and stops the page moving with it, which trades a silent bug for a sidebar
that mysteriously will not scroll -- a problem the dealer can see and cannot
explain, instead of one they cannot see at all.

The decision lives in Money as a predicate beside selectsOnFocus, so the rule
is testable without a browser and main.js only does the blurring.

Two things checked rather than assumed. The tagName guard in the predicate is
decoration: deleting it passes every test, because nothing but an <input>
carries type="number". That is now said in the comment instead of being left
to look load-bearing. And the page's one <select> -- which way the blinds move
-- is not vulnerable: focused, hovered and scrolled 300px in Chromium it stays
where it was, so it is left out of the rule rather than defended against.

916 tests pass, 6 of them new. Four fault injections into the predicate are
caught; a fifth was applied, not caught, and turned out not to be a fault.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 05:46:20 -06:00
jheapsandClaude Opus 5 fafbd8df32 Stop the things that actually move for anyone who asked them not to
The stylesheet had honoured prefers-reduced-motion for a while, in the three
places it mattered least: a 2px press-down on a button, a checkbox tick, and
the acting seat's pulse. What none of them covered was motion that moves
something across the screen, which for anyone with a vestibular disorder is
the part that matters. A button dipping under a thumb is not what makes people
ill.

Four things were doing it, and the three largest were in JavaScript where a
stylesheet cannot reach: the seat editor sheet, which slides the full height
of itself up from off-screen every time a dealer taps a chair; the toast; and
three scrollIntoView({ behavior: "smooth" }) calls, the most provocative of
the lot because what moves is the whole viewport rather than one control.

The scrolls go through Render.bringIntoView now, which reads the setting per
call rather than at page load -- a dealer who turns it on mid-session should
be taken seriously without reloading a table they cannot afford to reload. The
toast keeps its fade and loses its travel, because something that appears
already in place reads as a rendering glitch rather than as news. The sheet's
visibility delay flips at once, or it would leave a sheet that has already
jumped away still painted for a quarter of a second.

The press-down block held nine :active selectors and looked deliberate. It was
written to stop a stuck press state when a tap turns into a scroll, and had
reached for the reduced-motion query as the nearest proxy -- so it covered
.dealerChip and .raiseQuickBtn while missing .actionBtn, the class on Fold,
Call, Raise and Post blinds & deal. Read as its own rule the membership is not
a judgement call: every :hover and :active transform in the file belongs
there. There are sixteen. Nine were listed.

The scan that found the other seven took two goes. A line-by-line version
walked straight past a deliberately broken build, because the injected rule
was written on one line and neither "opens a rule" nor "is a transform"
matched. Parsing blocks caught it -- and then failed on the clean file, which
is how .toastAction, .toastClose and .potWizardChoice came to light: a
multi-line group had been truncated to its last line, so nothing had ever
looked at them.

910 tests pass, 16 of them new. Six script and stylesheet fault injections are
each caught, and the reduced/no-preference split is verified in a real browser
against both context settings.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 05:21:07 -06:00
jheapsandClaude Opus 5 0d878a176d Say in the sweep's own notes where the last five failures actually live
315 of 320 audits pass. The five that do not are one root cause on the two
smallest screens: twelve seats want about 60px of footprint each and the ring
has 44px of rim to give them, in the two states that make the bar tallest.

Written down rather than left to be rediscovered, because closing it is a
design decision -- drop the bet chips at extreme crowding the way the tight
tier already drops the hole cards, cap the seat count by viewport, or let the
felt scroll -- and each trades something a dealer can see.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 05:03:33 -06:00
jheapsandClaude Opus 5 7216f3da4d Stop a wrapped label costing twelve seats their room
The sweep had carried the same residual for rounds: on the two smallest
phones with twelve players, seats painting over each other's names and
stacks. It read like a geometry problem. It was a wrapped label.

"ALL IN" is the only thing that ever lands on the stack line with a space in
it. A stack figure has no spaces, so it can only overflow, and the
puckAmountLong/Huge sizes exist to stop that; nothing had asked what the line
does when it can break. Measured at 360x640 with twelve players all-in, it
wanted 36px and had 34, broke onto a second line, and an 11px line became 22.

That is not a typography complaint, which is why it survived: it looks fine.
The puck went from 55px tall to 66, and puck height is what the ring is short
of -- at the ends of an ellipse consecutive seats are separated vertically, so
a puck 11px taller is a puck 11px further into its neighbour. The layout
search then did exactly what it is written to do: tried every width it has,
found none that cleared, and fell back to the narrowest, which is also the one
that wraps hardest.

So the rule every design system gives a badge applies here too: one line,
always. Not wrapping is only half of it -- a line that cannot wrap and still
does not fit paints over the seat next door instead -- so on the tight tier
the mark gives up its letter-spacing, 3.6px of the 36 it wanted, and keeps the
warm colour and the bold weight that do the same job.

Halving the overlap did not clear it, and the rest of the shortage was not on
the felt. The sweep had been saying so in a warning rather than a failure: at
360x640 the bar was 267px of a 640px window, 42%, against its own "persistent
chrome under 30%". Two things in it were being shown when they could not be
used. The side-pot list is decision support, and during a run-out there is no
decision to support -- it collapses to the main pot and one line for the rest.
The status row said the player count twice in the same breath, as a chip and
again inside the run-out sentence beside it.

Measured: bar 267 -> 249 on a Galaxy S8 and 267 -> 227 on an iPhone SE, felt
243 -> 283 there, worst seat overlap 34px -> 4px. Across the sweep, overlap
offender lines 33 -> 9, chips behind seats 30 -> 5, failing audits 6 -> 5, and
the iPhone SE's seat-text overlap gone entirely. No new failures.

894 tests pass, 9 of them new. Seven fault injections are each caught.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 05:01:56 -06:00
jheapsandClaude Opus 5 e2bb935b43 Be there when the signal, the screen and the storage are not
Three failures that only ever happen at a table, none of which show up on a
desk with a good connection and a plugged-in laptop.

The offline promise depended on how the URL was spelled. Razor Pages routing
is case-insensitive, so /Casino/TexasHoldem serves the identical page; a
service worker's scope is matched as a plain string prefix, so a worker scoped
to /casino/ does not control it. The registration succeeded, the worker
activated, nothing was logged, and the cache stayed completely empty --
because the precache list was handed to navigator.serviceWorker.ready, which
resolves with the registration controlling the page and so never resolves at
all outside its scope. A dealer who opened that URL to load the tool for the
night had loaded nothing.

One spelling wins now, via a 301 scoped to /casino rather than applied
site-wide: every file under wwwroot/casino and wwwroot/images/casino is
already lowercase, so lowercasing there cannot miss a real file on a
case-sensitive filesystem. That guarantee does not hold for the rest of the
site, and /chessHub would break. The handshake is fixed rather than left to be
saved by the redirect -- it takes the worker off the registration itself.

The manifest icons were never cached, because the shell list is read off the
DOM and the DOM never mentions them. They are read out of the manifest now,
cache-first so an offline launch stops logging ERR_INTERNET_DISCONNECTED, and
the link sweep picks up apple-touch-icon, which is the only one iOS reads.

The screen slept mid-hand. A wake lock is held while betting is open and
dropped the moment it is not: through a whole street of renders it takes
exactly one, two syncs in a tick take exactly one, a hand that ends inside the
request leaves nothing held, and the browser taking it back leaves us able to
take it again. Announced once with a Turn off button rather than a permanent
header indicator; a setting after that.

And localStorage is evictable, which the Save & reset panel now says out loud.
persist() is asked for on Post blinds & deal, because every browser wants that
request to come from something the dealer did.

885 tests pass, 18 of them new. Five fault injections into the wake lock are
each caught. One of the new tests was green for the wrong reason first:
globalThis.navigator = fake does nothing, because Node has defined navigator
as a getter since v21.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 04:31:45 -06:00
jheapsandClaude Opus 5 75ec028c3e Ask for the hands at the showdown, the way the streets are already asked for
The card feature had two halves and only one of them ever asked a question. Turn the
setting on and every street opens a sheet for the cards coming off the deck — that
worked, on a phone and on a desk, in a normal hand and in an all-in run-out. The
other half never asked at all. A dealer who entered all five community cards, pressed
"Showdown — resolve pot", and got a wizard saying

    Christopher, Mike from work, Alexandra and Bob haven't turned over, so who
    won is still yours to say.

with nothing in that sheet to turn anybody over with. The only route in was to close
the wizard, tap a seat puck, find the two card slots in the seat editor, do it four
times and re-open the wizard, with the pot on the table and four people waiting. The
one moment the tool is certain to know the hands is the moment it stopped asking.

So the showdown button asks. The sheet opens on whoever the bar has just named as
showing first, takes their two cards, and moves round the table from there; hands
already entered are skipped, because a hand entered is a question already answered.
Four sheets later the wizard opens reading "Chris wins $40 with three of a kind,
nines" and its button says "Pay Chris" instead of "Confirm Winner(s)".

Leaving the sheet stops the asking rather than skipping a player: closing it says the
tool has the wrong end of the stick, and the answer to that is to get out of the way.
It isn't a dead end — the wizard still names whoever didn't turn over, the bar still
says "Showdown — resolve pot", and pressing it again resumes at the player the dealer
stopped on, because the list is read off the cards on the table rather than remembered.

CardPicker.close() takes a reason so the chain can tell "that sheet filled its last
slot" from "the dealer left". Two callers pass nothing and don't care.

The setting kept its behaviour and lost its jargon: "Track the board and read the
showdown" was the feature's name rather than what turning it on does to your evening.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 04:10:53 -06:00
jheapsandClaude Opus 5 e90bcccada Spend the accent on the one thing to do next, not on two at once
The green is a promise: this is the step. Measured over a whole hand — seventeen
steps — it was on two controls at thirteen of them, the bar's Call and the header's
Resolve Pot painting the identical gradient at opposite ends of a phone, and on the
header alone at the other four while the bar held the only button there was to press.
At showdown the most consequential tap in the hand, "Showdown — resolve pot", was
blue-grey while the corner shortcut was green.

So the accent moves to .actionAdvance, which is always the next step by definition,
and comes off the header shortcut, which never is. Twenty-two measured steps of a
real hand now show exactly one.

The sweep checks it: oneNextStep() counts the accent-coloured controls that are
actually on screen, scoped to an open sheet when there is one. Six broken versions
were run at it, and the two that took three goes to catch were both the same mistake
in the injection rather than a hole in the check — a button painted green with the
panel shut isn't rendered, and one painted green below the fold is deliberately
ignored. Scrolled into view it is named alongside the bar's Call, and goes clean
again when the green comes off.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 04:10:53 -06:00
jheaps 14fb68dc04 Merge pull request 'Hold'em dealer: making the table and the bar fit the screen they are on' (#119) from polish/holdem-round-next into master
Build and Deploy / build-deploy (push) Successful in 1m40s
Reviewed-on: #119
2026-09-08 19:08:12 -06:00
jheapsandClaude Opus 5 4a1cb842cb Say on each shut sidebar panel the one thing you'd open it for
The sidebar is eight <details>, and every one measured exactly 60px shut with
nothing on it but its own label — 480px of a phone's screen, and 480px of a 660px
desktop column, spent saying "Standings", "Hands", "Waiting list". Whether the box
balances, whether anyone is waiting for a seat, how many hands have been played:
the page had all three worked out already, and charged a tap for each of them, on
rows two thirds of which were empty.

That is the disclosure pattern's named failure — a header you have to open to find
out whether it was worth opening — and the answer the design systems give is the
same one: put the status on the header. The panel keeps the sentence. "Off by
$300" is the alarm; which seat is holding chips nobody wrote down is a paragraph,
and that is the half that belongs behind the tap.

Two rules, and panelGlance.test.js checks both. A glance appears only when it has
something a dealer would act on — eight rows that always say something are eight
rows that say nothing again, and a row reading "Nobody waiting" all night is the
clutter this went in to remove. And a glance never says something the panel under
it would contradict: it is read off the same state, never off the panel's DOM,
which for a shut <details> isn't rendered at all.

Absolutely positioned rather than floated. A <summary> only draws its disclosure
triangle while it is display: list-item, so it cannot be a flex row; the float
that replaced it kept the row at 60px but aligned to the label's line box, and a
12px string in a box sized for 15px text rode 2.5px low on all six panels. Out of
flow is also the smaller promise — an absolutely positioned box cannot make its
row taller at all.

Three paths deliberately skip renderAll, because it would take the field out from
under whoever is typing in it, and all three move something a glance reports. The
notes textarea, the chip legend's value fields and commitSeatChange each call
renderPanelGlances on their own instead; without that the note you had just typed
was summarised by the note before it until the next tap landed. A space between
the label and the glance, too: the summary is announced as one button, and without
it a screen reader was given "Table Setup9 seats".

Costs 0.08ms of a 4.79ms renderAll on a twelve-handed phone. Four injected faults
in the wording are caught one test each.

glancesFitTheirRows took three goes. The first asked only whether a row had grown
taller than a bare one, and a glance floated back into flow and allowed to wrap
sailed straight through it, because a float does not expand the box it sits in.
The second was caught by the sweep on its first clean run — 94 failing configs, 91
of the offenders this check's own: it takes the shortest row as its idea of a bare
one, and the Keyboard panel is display: none under pointer: coarse, so on every
viewport visited with a thumb the bar was a row that wasn't drawn, and all seven
real rows read as grown. Rows that aren't rendered aren't rows. The four broken
layouts are re-run with that panel hidden and each is still caught.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-06 01:56:58 -06:00
jheapsandClaude Opus 5 e7ddff2fed Let a large felt have larger seats, and the type that goes with them
The felt grows with the window and the pucks did not: 96px was the only
ceiling there was. Nine-handed, the seats covered 19% of the felt on a phone,
11% on a 1280px desktop and 8% at 1440px and beyond — and the stack figure a
dealer reads sat at 10px on a 2560px monitor, the same 10px as on a 360px
phone, well under every published floor for text meant to be read.

Two things stop this costing more than it buys.

Every candidate width is a forced layout. Simply raising the ceiling took the
layout pass from 24ms to 73ms twelve-handed on a desktop, so a screen that
cannot use the wider widths must not measure them: the extra pass is offered
only when the arithmetic already says the seats have room to spare, and a phone
measures exactly the ten widths, in the same order, that it measured before.
Timed with the order alternated so neither carries the warm-up: 10.7ms against
10.4ms on a phone, and +3 to +6ms where the widths are actually used.

And a wide puck on a narrow felt is worse than a small one. The first version
of this let phones through, and a 144px puck on an iPhone's 278px ring is half
the width of the table: six-handed, all six seats sat over the rail by 15px
against 5px before, reading as a stack of cards rather than a table with people
around it. So the ceiling is a fifth of the felt's shorter span, capped at 144.
Five is measured rather than chosen — the shorter span is 233-303px on every
phone in the sweep including sideways and 617-714px on tablets and desktops, so
any divisor between 3.2 and 6.4 separates them — and the test pins both ends.

Above the ordinary ceiling the puck takes the larger type as well, which is the
point of the extra width. Nine-handed: an iPad goes 96px/12px to 134px/15px, a
1440px desktop 96px/12px to 140px/15px, and the seats go from 8% of the felt to
12%. Every phone, at every seat count, is untouched.

The sweep is byte-identical to before this change: same six configurations,
same 63 offenders, empty diff.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-06 00:56:59 -06:00
jheapsandClaude Opus 5 f8ed4abc85 Stop two empty rows in every seat puck setting the size of the table
A seat puck carries the name, the hole cards, the amount that player has put
in, and their stack. Two of those are empty most of the time and both held
their space anyway.

The amount pill was worse than empty space. It has a background, a pill radius
and side padding, so with nothing in it, it painted an 8x11px green lozenge —
measured on seven of nine seats at an ordinary table. One of those is a
component holding its place; nine is a rash, and a dealer has to learn to
ignore a colour that on the other two seats means "this player has money out".

The space was not free either. seatRingRadii sizes the ring from the tallest
puck on it, so two rows nobody had filled were setting how far apart the whole
table had to sit. Across 21 tables — seven viewports, six, nine and twelve
handed — every single puck comes back shorter and six come back wider: an
iPhone 14 nine-handed 78x88 -> 78x63, twelve-handed 44x65 -> 66x55, which is the
one name in the set that was clipped and now is not.

The collapse is only safe because the layout cache moved with it.
resolveSeatLayout measures ten candidate widths, each a forced layout, against
seatShapeSignature() — which listed the names and the dealer seat, everything
that could wrap a name onto a second line. It did not mention these two rows,
because while they always held their space they could never change a puck's
height. Now they can, so the signature names them: booleans over the whole
table, because measuredSeatBox takes the tallest puck on the ring, and because
a per-seat key would be correct and would throw the cache away on every tap.

The ring settles by 24-26px when the blinds go in — and it settled by 23-25px
there before this round too, since the action bar changes height at that moment
and the felt is measured against it. The one place it moves is a place it
already moved.

uiAudit gains emptyDecoration(): nothing on the felt may paint a background or
a border with no content in it. Its one exemption is on the list because the
check reported it rather than because it was predicted — .betChipDisc is a
circle filled with the denomination colour of the chip beside it, so the colour
is the content. Three others were written from memory first and matched no
element on the page at all.

The sweep is unchanged at 6 failing configurations and 63 offenders. All four
table failures are the all-in shape, where every pill is filled and the
collapse buys nothing; the ordinary tables this helps were already passing, so
what moved is room a name has rather than whether anything was covered.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-06 00:09:25 -06:00
jheapsandClaude Opus 5 942919f3f7 Stop the side-pot list growing the action bar over the felt
The bar is fixed over the table, so its height is the felt's budget — and it
carried a pot breakdown that drew one row per all-in with no ceiling. On a
360x640 phone: five players idle put the bar at 151px, six all-in at 303px,
twelve all-in at 411px. That is 64% of the screen, leaving 96px of felt to draw
twelve seats in, and at that size the seats stop being a table: five pairs
collapse onto each other's centres, ten hang past the rail and a dozen labels
sit completely covered.

Past four layers the bar now draws the main pot, one line for everything
between, and the newest side pot. The two ends are the live ones for the same
reason — side pots are paid from the newest back down to the main pot, so the
top layer is the next thing that happens and the main pot is the last, and
those are the two a dealer says out loud while the chips are stacked. The
middle is the rest of that count-down, wanted at showdown, which is the pot
wizard: same builder, no cap, in a dialog that scrolls. The elided line carries
the count and the money, so the visible rows still add to the announced pot.

Nothing in the sweep could see any of this, and the reason is worth keeping.
Every check asks whether an element is covered or clipped; none asked how much
room the covering element had taken. But the deeper miss was the sweep itself —
all 294 audits drove their table with Call and Check, so no table it ever
measured had a player all-in, and the one thing on the bar whose height depends
on the table was empty in every measurement. There is now a fourth table shape,
twelve players shoved in for twelve different amounts, and furnitureBudget()
measures the fixed band against the window: failing over half of it, warning
over the 30% that persistent chrome is usually held to.

Galaxy S8, twelve all-in: bar 411px -> 267px, felt 96px -> 192px, 91 layout
offenders -> 25. iPhone SE: 73 -> 10. The rest is the seat ring on a short
phone, which the sweep can now see.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-05 23:34:51 -06:00
jheapsandClaude Opus 5 02c2a034cb Stop the fixed bar and the toast sitting on the keyboard's focus
WCAG 2.2 SC 2.4.11 asks that a focused control not be completely hidden
by something the page has put on top of it. The bar is fixed over the
bottom and is not in the flow, so the browser does not know it is there:
Tab to a control near the bottom and it is scrolled flush against the
edge of the window, under the bar, and the browser considers it done.

Tabbing the whole page measured 16 stops resting completely covered on a
390x844 phone and 27 more partly, plus four under a toast on the desktop.
It is now 0 and 0 on desktop, phone and a phone on its side — clean
against 2.4.12 (AAA) as well, which allows no overlap at all.

Three things were wrong and they needed different answers.

scroll-padding-bottom on the scroll container, sized from the bar's own
published height and the toast's, handles the case where the browser has
to scroll to reach the control. That is half of it, and the half that
looks like all of it: a control the browser already considers visible is
one it will not scroll to, and a fixed overlay on top does not enter into
that decision. Measured — tabbing to Common rulings with a toast up left
it 100% covered and scrollIntoView({block:"nearest"}) moved nothing. So a
focusin handler measures the overlap and scrolls by it, and when the page
has no room left (26px against 94px wanted) the toast gives way instead.
Sizing the page padding for the toast would have fixed that by making the
page grow and shrink 64px whenever anything was announced.

The page's room to scroll past the bar was 9rem on the sidebar alone,
which left the table's column — most of a desktop — with none. It is now
on the whole app and taken from the bar's real height, so it is also 0 in
the layout where the bar is a rail down the side.

And the seat editor's bottom sheet was put away with a transform, which
moves a thing without putting it away: its Done button sat at y=850 on an
844px screen, focusable, announced, and impossible to scroll to, because
a fixed element does not move when the page does. visibility:hidden puts
it away, transitioned so the slide-out still runs.

uiAudit.js gained focusHasRoom() and focusablesReachable() for the two
static halves. Audit unchanged at 2 failing configurations and 3
offenders, both pre-existing. 805 unit tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-05 22:57:58 -06:00
jheapsandClaude Opus 5 cd2db6346e Stop a chip colour taking the table's money with it, quietly
Deleting a chip colour was one click of an unlabelled 24px button, and it
was never a change to a legend. computeContribution() prices a seat by
running the legend over that seat's chip counts, so a colour that leaves
takes every chip counted in it off the table.

Measured in a browser on a three-handed table: deleting the $500 row took
$22,500 of $26,874 with it. No confirmation, nothing said afterwards, and
no way back — a colour added again is a new colour with a new id, while
the counts stay keyed to the one that left. The counts were left behind
too, so the saved file carried money nothing could price and nothing
could show.

Now: a colour nobody has counted still goes on one click, and one that is
holding money asks first, naming the amount, the chips and the seats, and
saying that Undo will put it back while adding the colour again will not.
Conditional on purpose — a confirmation that fires every time is one
nobody reads by the third night, and four others already compete for that
attention here. Removal goes through runUndoable, and the counts go with
the colour so the file matches the screen.

The number in that warning is the difference between pricing every seat
with the colour and without it, not chips times value. The obvious
arithmetic is wrong both ways: a seat with a typed amount is never priced
from its counts, and a seat whose chips all leave falls through to the
table's standing ante rather than to zero. A randomised check over 2,000
tables asserts the warning equals what the table then does, and it found
a third case — fourteen $1 chips against a $200 ante, where removing the
colour puts the table UP $186. The wording says so in that direction too.

The × also had no accessible name, so uiAudit.js gained controlNames():
every control needs a name containing a letter or a digit, resolved the
way the accessibility tree resolves it. A symbol is not a name. Taking
the new aria-label off again makes it report all five buttons.

Audit unchanged at 2 failing configurations and 3 offenders, both
pre-existing. 805 unit tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-05 22:26:16 -06:00
jheapsandClaude Opus 5 6eb97c8a35 Give the sidebar eight names instead of five and a cupboard
"Session" was a container word rather than a name. It held the standings,
a waiting list, the hand log and the save/load/reset buttons — a topic, a
task, a log and three utilities under one label, which is a navigation
layer with more than one logic in it.

It measured 1206px and 103 visible elements, against 555 and 29 for the
next biggest, and "The table itself" sat 1052px down inside it. Reaching
Reset table meant opening a panel called Session and scrolling past the
money and the log to get to it.

It is now Standings, Waiting list, Hands and Save & reset, each named for
the thing a dealer came looking for, in the order of a night: set the
table up, watch the money, fill the seats, look back at a hand — and,
last and apart, the two buttons that end it. Reset table goes from 1052px
inside a panel called Session to 62px inside one whose label says what it
does. The biggest panel drops from 1206px to 607px. The four subheads
that used to carry those names are gone, because the summary says it now.

Every panel name is also a heading. A <summary> is announced as a button,
so the sidebar's names — its whole navigation — were unreachable by
heading, and splitting Session would have removed the only four headings
down there. The outline is now h1 page, h2 per panel, h3 for the two
subheads that remain. Visually identical: the h2 gives up everything a
heading brings with it.

The cost is 216px more collapsed summaries at rest, three more one-line
labels. Audit unchanged at 2 failing configurations and 3 offenders, both
pre-existing. 795 unit tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-05 21:52:34 -06:00
jheapsandClaude Opus 5 bd8ee11f77 Say who can win each pot in the fewest names that answer it
The pot breakdown listed every eligible player for every pot. Pot
eligibility is nested — the main pot is everyone still live and each side
pot above it drops whoever was all-in below — so the main pot's line was
every name at the table and each side pot repeated it minus one.

The lines too long to fit were exactly the ones nobody needed spelled
out, and the short ones at the bottom were the side pots a dealer
actually announces by name. Measured on an iPhone 14: a single all-in
five-handed already clipped the main pot, and twelve-handed the first row
wanted 521px of a 254px slot. There was no count and no title, so a
dealer reading "Alexandra…" could not tell whether three people or eleven
were eligible — the number the showdown turns on.

Each line now names whichever of "who is in" and "who is out" is shorter,
which nesting guarantees is never more than half the table, and leads
with the count because the line is right-aligned and it is the tail that
gets cut off. "all 9", "8 in, not Mike from work". On a phone this takes
one, two and three all-ins from every main-pot line clipped to nothing
clipped at all; twelve-handed goes 6 clipped rows to 5, and even a cut
row still opens with "9 in".

The wizard's copy of the same announcement wraps instead — it is a dialog
that scrolls, and it is what the dealer reads out before anybody shows a
hand — so nothing there is ever cut off. The full list is also on the
title for anyone with a pointer, which is the extra rather than the
answer, since a tooltip is not a mechanism a keyboard reaches.

potCalculator.test.js now proves the nesting across 20,000 random tables
and asserts on its own generator, so the loop cannot go vacuous. Audit
unchanged at 2 failing configurations and 3 offenders, both pre-existing.
787 unit tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-05 21:23:19 -06:00
jheapsandClaude Opus 5 13d704301d Stand the felt up on a phone, and check which rule actually wins
A portrait phone got a square felt, so the vertical room the layout had
already measured was published and then dropped: 522px of room under a
366px table on an iPhone 14, 638 under 406 on a Pro Max. The cost was
seat names — 44px pucks with five of twelve clipped.

--feltAspect: 1.5 spends it. Twelve seats at 390x844 go from a 44px puck
with five names clipped to 66px with one; five seats go from three
clipped to none; the Pixel 7's twelve-seat ring goes to zero. 1.6 and
1.7 measure byte-identical, so 1.5 is where the sweep stops paying. The
three smaller phones are room-bound at any cap and measured identical
from 1.15 to 1.7.

The first version of this was a no-op. The rule went into
@media (max-width: 700px), and @media (max-width: 900px) below it set
the same property back to 1 — both match a phone, same specificity,
later one wins. It measured as 1 on every phone while reading perfectly.
The 900px rule is now a 701-900px range, and feltShape.test.js resolves
--feltAspect through the media queries the way the cascade does so the
next one of these is caught.

Also tried and removed: a floor under the max-height, so a tall bar
couldn't press the table flat. Twelve all in with nine side pots takes
the bar to 341px, leaving 142px of room at 360x640; floored to 242 the
felt keeps its shape by putting five of twelve seats behind the bar,
unfloored it is flat with all twelve visible. The slit it defended
against came from forcing the custom property by hand. A test asserts
its absence with the numbers.

Audit: 4 failing configurations and 5 offenders down to 2 and 3, both
remaining ones pre-existing. 776 unit tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-05 21:02:34 -06:00
jheapsandClaude Opus 5 7bd205a91a Line the bar up with the table, and stop the button squeezing a name
Two things that were wrong at every desktop width, both of them the same shape: a
number that was right about one thing being used for another.

The action bar was centred on the window. The table is centred on its own column, and
the sidebar takes 320px out of the right of that — so the two things a dealer is
actually looking at sat 172px apart, at 960px wide and at 1600px and everywhere in
between, because the offset is half the sidebar and the sidebar is a constant. The bar
also finished 80px inside the sidebar's column at 1280, painting over whichever panel
happened to be at that height. It leans away from the sidebar now, by the sidebar's own
width, which comes out exact above and below the 1400px cap — measured on the running
page at four widths, 0px off at all of them. No 100vw in it, which is the point: the
bar's own centring comment warns that on a page with a scrollbar 100vw is wider than
the viewport, and the offsets are exact without it.

The dealer button's chip sits in the puck's top-right corner and the name kept out from
under it with `padding-right: 22px`. That takes the chip's width off *every* line of the
name, and the chip is on one of them. Measured at 1280x900: the name box is 78px,
"Christopher" is 66 and fits, and on the seat holding the button it was cut to 56 and
broke as "Christop / her" — mid-word, no hyphen. The button moves every hand, so that is
a player whose name reads properly for four hands and comes apart on the fifth.

A float is what indents only the lines it sits beside. Line one gets 56px and the rest
get 78, so a name too long to fit beside the chip drops below it whole instead of being
broken to fit next to something that isn't there.

Not on a crowded ring, though, and finding that out cost a sweep. A tight puck gives the
name one line on purpose, and one line has nowhere to move to: with two lines granted
the name dropped onto a second line that the chip's lower half was still sitting on, and
the audit called it at every viewport in the list. So the float is for the ring with room
for two lines and the padding for the one without — two rules, matching two regimes the
puck already had.

The sweep also learned two things from this:

- Text is measured where it was painted, not where its box is. The seat-label check
  modelled "text kept out of somewhere" as padding, which is one way of doing it and not
  the only one; a float does it per line, and the check called every seat in the sweep an
  offender while the text sat exactly where it was meant to. Range rects ask the question
  directly, so padding, floats, centring, wrapping and a short label in a wide box all
  come out right without any of them being modelled.

- Offenders say how much is covered, not that something is. "Is under a badge" reads the
  same for a stack a dealer cannot read and for a badge's rounded corner grazing the last
  digit. Probing the whole line and counting turns the residual this sweep has carried for
  three rounds into "1/7 under SB" — one probe of seven, one configuration, thumb only.

294 audits, 4 failing configurations, 5 offenders: twelve players on a 360px phone with
the raise pad open (3/7 and 2/7, pucks genuinely overlapping — the search reports it
rather than papering over it) and the 1/7 hairline above. 770 unit tests.

Fault-injected, all restored and re-verified: writing the sidebar's width out again
instead of the token, growing the float to cover the chip's whole height, and taking the
padding off the crowded ring are each caught by a test; and with both the float and the
padding removed the rewritten label check still reports the name 6px under the chip,
which is the fault it exists for.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-04 18:17:16 -06:00
jheapsandClaude Opus 5 f07144acd8 Give the table the screen back when the phone is on its side
A phone held sideways is 640x360: short of height and swimming in width. The layout
spent the whole of that shortage on one column — 70px of header, 85px of felt, 205px
of action bar. Two rows of seat pucks 55px tall have to fit inside 85px of felt, which
they cannot, so seven of nine finished past the rail with up to 17px of puck floating
on the page. That single fault was 96 of the 98 offenders the layout audit could find
anywhere.

No amount of shrinking fixes it, because the shortage was never in the table. The bar
is 205px tall and 529 wide to hold three buttons and a status line, and it was taking
that height from the one element that needed height. So in landscape it moves: the bar
becomes a rail down the right-hand side, the table takes the column beside it, and the
felt goes from 529x85 to 409x205 — an actual oval, with room for two rows of pucks and
a gap between them. It also puts Fold, Call and Raise under the right thumb rather than
along the bottom edge, where a landscape grip has the palm.

Which layout the bar is in is declared by the stylesheet in a custom property and read
back by actionBar.js, because the one thing that must not exist is a second copy of
that media query written in JavaScript.

Then the ring stopped predicting itself. seatRingRadii works out the widest ring whose
outermost puck still finishes inside the rail, and it is right about a puck it measured
before the width search settled and before a name wrapped or was shortened — each worth
up to 15px of height arriving after the answer. Four viewports finished with the top
seat 5-6px over. render.js now checks the drawn result the way the audit judges it: if
the worst puck hangs 6px past the rail, the ring comes in by 6px and they are placed
again. A layout that predicts itself has to be right about the future; one that measures
itself only has to be right about the present.

Also in this round:

- Names shorten at a word instead of being chopped mid-one. "Mike from work" on a
  nine-handed phone was "Mike f…"; he is Mike. A name never ends on a joining word,
  because "Mike from" looks cut off rather than shortened, and the whole name is on the
  tooltip either way.

- The blind badge is counted in what a seat needs. It always points away from the table,
  which was taken to mean it costs nothing between two seats — true of a straight rail
  and false of a ring, where neighbours sit at different heights. Three tables had a
  player's stack painted under the next player's SB or BB, and that badge outranks
  everything else on the felt. It also got shorter, 15px to 12: clearing its own name at
  5px of overhang instead of 9 hands every chair in the ring four pixels back, which at
  640x360 is the difference between a 66px puck and a 48px one.

- Border-box, inherited, for the whole app rather than nine elements opting in one at a
  time. Both elements that hadn't went wrong in the same afternoon: the rail overran the
  page's padding by 25px and put Resolve Pot underneath itself, and the raise pad's
  amount field pushed a horizontal scrollbar across a 640px phone.

- The raise amount field is thumb-sized. The rule that sizes fields for a finger names
  the settings panels and the seat form, so the one field on the one surface built for
  thumbs was 36px on every phone in the list. The sweep never saw it because it had no
  step that opened the raise pad; it has one now, and that step also caught the rail
  squeezing its own status line under the pad.

- mobile-web-app-capable alongside Apple's older name, which Chrome has been calling
  deprecated in this page's console on every load.

The audit goes from 19 failing configurations and 98 offenders to 2 and 3, across 294
audits — thirteen viewports, three table shapes, a mouse and a thumb. What is left is
twelve players on a 360px phone with the raise pad open, where twelve pucks at the 44px
minimum genuinely do not fit on the felt; the search reports that rather than papering
over it. Each new mechanism was checked by breaking it on purpose: removing the settle
pass puts 26 offenders back, dropping the badge from the footprint 5, and un-sizing the
raise field is caught by the step that now opens it.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-04 17:19:11 -06:00
jheaps fbc4bd9a3e Merge pull request 'Hold'em dealer: a felt that fits, and a table that reads its own showdown' (#118) from polish/holdem-seat-collisions into master
Build and Deploy / build-deploy (push) Successful in 1m11s
Reviewed-on: #118
2026-09-04 15:28:46 -06:00
jheapsandClaude Opus 5 cd57c16050 Let the table tell the dealer who won
An option — off by default, and off means the tool is exactly what it was.

Turn it on and the community cards go on the felt. Each street asks for the cards as
it is dealt: the picker opens on the flop the moment the flop button is pressed, and
again on the turn and the river, because the moment a dealer is holding those cards is
the only moment they know what they are without turning the board round. Three taps,
then one, then one. The picker is the same sheet as the hole cards, which now knows
about the middle of the table too — the seven of clubs cannot be on the flop and in
somebody's hand, and until now it could.

Turning the players' cards over is unchanged. That already worked the way it should.

What is new is what happens next. Resolve Pot stops asking who won and says so:

    Alexandra wins $50 with three of a kind, nines.

with every hand at the table read out under its player's name, the winner already
ticked, and the button reading "Pay Alexandra". A chop comes out as "Christopher and
Mike from work split it, $22.50 each, with a pair of aces", both ticked. The dealer
still confirms — the tool reads the cards, a person still says the pot is good, and a
hand entered wrong is corrected by unticking rather than by starting over.

It declines rather than guesses. A board that isn't five cards, a player who hasn't
turned over, the same card entered in two places: each says which of those it is and
hands the question back, and the wizard is the one it always was. Who is *ahead* and
who *won* are different claims and only the second one can pay somebody.

Side pots are read per round, not once. The best hand takes the main pot and is then
out of the question; the side pot goes to the best of who is left, which is a different
answer and the one the tool has to ask for again.

handRank.js is the only module here that can pay the wrong person without anybody
noticing, so it is built to be checkable rather than clever: rank five cards, then take
the best of the twenty-one five-card hands in seven. Beside the written cases — every
category against every other, the wheel and the steel wheel, three kickers deep, the
counterfeited two pair, the board that plays — it is checked against a second evaluator
written a different way, on twenty thousand random seven-card hands and four thousand
random pairs of them. An ace that keeps its value in the wheel, a dropped two-pair
kicker and a flush read only by its top card were each introduced on purpose and each
caught.

The board takes the middle of the felt, sized from the room the seat ring actually
leaves — measured against every seat rather than the ring's widest point, which is only
the answer for a seat sitting exactly on the horizontal axis and had two pucks on top
of the board while claiming 77px of clearance either side. Where there is no middle to
take, it moves to a strip above the table: a phone on its side has a felt 528x83 with
55px pucks, so the ring closes over the centre entirely and a board in it is a board
with players painted on top.

That strip found an older bug. It costs the felt height, and a felt that changes size
had only ever been re-measured, never laid out again: the seats follow it on their own,
being placed in percentages, but a bet chip anchored inside a seat is placed in pixels
off that seat's centre. Measured at 640x360, the chips were positioned for a felt 83px
tall and painted on one of 60 — seat 3's $5 came to rest on seat 4's puck, and two
pairs of seat pucks were overlapping. The bar growing mid-hand does the same thing: a
side pot appearing is 55px off the felt. Both fixed.

The sweep grew a third table — the same five players in the same nine chairs as the
second, with the board on, so the only thing that differs between those two rows is the
board itself. It earned its place on the first run.

728 unit tests. 216 audits: what is left is the seat ring hanging past the rail on a
landscape phone, the known residual, plus five or six pixels more of it on the layouts
where the strip takes the felt's height, and a blind badge over a stack on a packed
twelve-seat portrait table.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-04 11:18:33 -06:00
jheapsandClaude Opus 5 2a080eab6a Give every bet on the felt somewhere to be
The chips were the last thing on the table nobody had measured, and the sweep had
never seen one: it audited a table that had never had a bet placed on it, so a third
of what is on the felt during a hand went unlooked at for the tool's whole life. It
posts blinds, deals and calls three times now before it measures anything.

What that found, and what this fixes:

- Chips accumulated. Seats are kept and updated in place rather than rebuilt, so
  clearing the bets layer stopped clearing the ones anchored inside a seat and
  nothing else ever did. An iPhone 14 had one chip per seat after the deal and five
  by the third render; a dealer's every tap left another disc on the felt.

- The ring-or-cling decision asked how wide the window was. A phone held sideways is
  844px across and gives the felt 117px: it passed the width test, took the ring, and
  every chip on it was painted under a puck — the seats carry z-index 1 and the bets
  layer carries none, so a chip that lands under one is gone, and with it the only
  number saying what that player has in. Measured now, and only measured.

- Getting clear of a box was `edgeReach(puck) + edgeReach(chip)`, which is right only
  straight up, down or sideways. Two axis-aligned boxes are clear when they are apart
  on one axis, so the travel is the reach of the box you get by adding their widths
  and their heights. At 768x1024 a $10 leaving a seat on the diagonal wanted 88px and
  got 77, and came to rest 11px inside its own player's puck — every seat off the four
  compass points, which is most of them.

- Every chip was laid out at the first chip's size. A chip is as wide as the number on
  it: "$5" is 21px and "$10" is 27px on a phone, so each $10 was placed as if it were
  6px narrower than it is. The widest is no better: at 768x1024 one 47px chip made the
  whole table give up a ring the other four had room for.

- Anchored chips settled where they landed. At 640x360 the felt is 528x83 and a puck
  is 55 of those pixels tall: clearing it takes 41px, the players facing each other are
  70px apart, and both chips set off 41px into a gap that is 70 wide. Three overlapping
  pairs on a twelve-handed table. Each chip now gives ground only to something it is
  actually standing on, a pixel at a time — a midline rule or a single scale factor
  both punish the seats at the ends of a rail, which were never going to hit anything.

- Nothing knew the pot total was there. Seat 1's $10 was sitting on "Pot $65".

Two things off the felt, from the same sweep:

- The toast outlived its news. "Next hand — button on seat 2. [Undo]" stayed up while
  the hand was dealt and three players called: an offer to move the button, wired to a
  snapshot that predates the hand, so taking it would have thrown the hand away without
  saying so. It was also in the way of thirty small screens' seats and controls. The
  bar drops it as it takes the next action; the bar's own Undo names what it would
  actually take back.

- The pot dialog cut a name off. Three players in the pot on a Galaxy S8, two rows
  visible, the third below a hairline that reads as the end of the list — a dealer
  ticking the two they could see would have paid two thirds of a three-way pot. The
  85vh budget was still subtracting the action bar's height from a dialog that has sat
  above the bar since its z-index was fixed, and a player's ceiling moves beside their
  name on a short screen. All three fit now, and where a list does run on, it runs on
  under a fade rather than stopping dead on a row boundary.

The pot badge goes on a phone held sideways: 86x66 of a 692x113 felt, for a number the
action bar prints three inches below it, standing in the only band a bet chip has.

The audit stopped calling one thing another. An element clipped away by a panel that
scrolls is not covered by whatever is painted at that height — the wizard's third
winner came back for four viewports as "fully covered by button Confirm Winner(s)".

190 audits: 25 failing configurations to 7, and every one of those seven is the seat
ring hanging past the rail on a landscape phone, which is the known residual from the
last round. 663 unit tests, 44 of them new.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-03 17:05:47 -06:00
jheapsandClaude Opus 5 f93e0935ef Give every seat its own pixels
Six players at a nine-handed table is the most ordinary table this tool draws, and on
every phone in the sweep one of them had their stack painted under the player next to
them. Measured across nine viewports and six table sizes: seventeen of fifty-four
tables hid a name or a stack behind something belonging to another seat.

Two causes, and the layout only knew about a third of one of them.

The felt decided how big a puck could be from a single number — the straight-line
distance between the closest two *neighbouring* seats, judged against a fraction of a
puck's width. Distance has one number in it and a puck is half again as wide as it is
tall, so the same 84px was generous side by side and nowhere near enough on the
diagonal. And neighbours are the wrong pairs to ask about: on a phone held sideways the
felt is 528x87, the ring is very nearly a straight line, and the seat that lands on top
of you is the one across the table. Ten of the tables in the sweep had a collision that
a neighbours-only check cannot see — seats 1 and 7, overlapping by 68px, while every
adjacent pair had room.

seatsCollide() asks the real question instead: do any two of these boxes want the same
pixels. Two equal axis-aligned boxes overlap exactly when their centres are closer than
one box in both axes, so it is an answer rather than a proxy, and it looks at every
pair.

The second cause was the small blind badge, sitting at `left: -6px` — hanging off its
own puck's left corner onto whatever was beside it. On an iPhone Pro Max at six seats,
with the pucks not touching at all, "SB" was painted across the next player's $1,137.
All three badges now sit inside the puck's width, and in the same place as each other:
a seat only ever wears one, so two positions bought nothing except making the dealer's
eye find which of two spots this seat was using before it could read which blind it is.

Then the layout had to be able to act on the answer, and it couldn't. It chose between
two widths — 96px or 58px — and if neither fitted it widened the ring and let the seats
overlap, on the reasoning that a dealer can see an overhang but cannot see a tap landing
on the wrong player. That trade is real; it was also a false choice, because the tables
that overlapped did not want either number. At nine seats a Galaxy S8 had room for a
61px puck, an iPhone 14 for 68, a Pixel 7 for 74, an iPhone Pro Max for 79. Three media
queries declared three more of these numbers — 78px, 64px, 62px — at three breakpoints,
guessing at something a media query cannot see: the same 390px phone has room for a 90px
puck at four seats and 61 at nine.

So the width is searched for now, not chosen. render.js walks candidates from the top,
applies each one, measures what the browser actually drew, and takes the widest that
leaves every seat its own box. There was a width that worked on every table in the
sweep; the code was never asking for it. Ten candidates is ten forced layouts, so the
result is cached against the felt's size, the seat count and the names — measured 12.5ms
cold, 0.8ms warm, and renderSeats runs after every tap.

Three things this got wrong on the way, all caught by measuring rather than reading:

  The badge overhang was folded into the space between two seats. That collapsed the
  ring on a phone held sideways — 87px of height, and 18px of badge is most of the room
  the ring has to move in — until a six-handed landscape table was unsatisfiable at any
  width. It didn't belong there: the badge points *away* from the table at both ends, so
  two seats facing each other across the felt wear them back to back.

  box-sizing: border-box, which the puck needed so the searched number means the width
  it is drawn at, quietly turned every min-height from a content height into a drawn one.
  An empty chair went from a 52px tap target to a 42px one on every phone in the sweep.
  The audit found it; the four min-heights have each gained the padding and border they
  used to be quoted without.

  The new audit check reported a name under a neighbour on a table whose seats were 9px
  apart. A puck's name is a `-webkit-box` with `line-clamp`, and getClientRects() hands
  back a rect for every line the text *would* have taken, including the ones the clamp
  throws away — so the probe was hit-testing 30px below the puck, where nothing is
  painted. It clips to what is painted now.

uiAudit gains "no seat covers another seat's writing", the complement of the check
already there for a puck's own decoration on its own name — which is why nothing caught
this: seatLabelsClear() only ever looked inside one seat. Fault-injected against the
previous version: eighteen configurations, naming both causes.

And the sweep itself now runs two table shapes. It had run one for its whole life —
five players at a nine-handed table — and how crowded the felt is depends on how many
people are sitting down far more than on anything a viewport can report. A full table
was never once looked at.

632 tests, 0 failures. 190 audits across thirteen viewports at two table shapes, with a
mouse and with a thumb. Every table from two seats to twelve now keeps its own text on
every viewport in the sweep. One configuration still reports: a dozen players on a
640x360 phone, where the felt is 87px tall and two rows of 55px pucks cannot both fit
and stay on the rail — the ring grows 8px past the edge to keep the seats off each
other, which is the trade the old code named and got right. Left reported rather than
tuned away.

Names on a crowded phone table truncate harder than they did, because the puck is now
sized to fit instead of allowed to overlap: "Christopher" is "Christ…" at nine seats on
an iPhone 14. The full name is in the DOM, in the seat's form and in the sidebar. The
next lever on that is the seat layer's inset, which is still a fixed 44px of "half a
puck" for a puck that is no longer 96px wide — worth 54px to 60px where it was measured.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-03 13:23:31 -06:00
jheaps 190ccebf6c Merge pull request 'Hold'em dealer: a reachable Confirm, a table that fits, offline, and a quieter first screen' (#117) from polish/holdem-offline-and-render into master
Build and Deploy / build-deploy (push) Successful in 1m42s
Reviewed-on: #117
2026-09-03 12:24:00 -06:00
jheapsandClaude Opus 5 c052ee32c8 Put the Confirm button back on screen
Opened the pot wizard — the one dialog in this tool that divides money — and its
Confirm button was below a scroll. Not on a tall table, not on one screen: at
every size I measured, with six players and a side pot.

  iPhone 14 390x844   244px of the dialog below the fold
  Galaxy S8 360x640   417px
  844x390 landscape   583px
  desktop 1280x900    172px

Nothing on screen said there was anything under the list of names, so a dealer
with the table waiting had to scroll a list of players to find the only control
that finishes the hand.

Two goes at this, and the first was wrong. A sticky footer is the obvious
answer and it cannot work here: the actions were the last child of the
scrolling content, so their containing block ended exactly where they did and
`bottom` had no room to lift them. Sticky on the *header* did work, for the
mirror-image reason — its container extends below it — which is what made the
failure confusing to read. Capping the player list wasn't enough either; the
pot breakdown and the log still overflowed on a short screen.

So the dialog is a proper column now: a title that doesn't move, a body that
scrolls, and a footer outside the scroll. potWizard.js appends its buttons to
the footer instead of into the body, and clears both together so a step can't
inherit the previous step's buttons. Measured after: Confirm on screen and
hit-testable at all six configurations, twelve-handed on a Galaxy S8 included,
and the dialog itself no longer scrolls at all.

The checkboxes are painted now, too. `accent-color` only ever coloured the tick
— the box around it stayed the platform's own bright white square, which on a
near-black dialog was the loudest thing on screen and the last raw control in a
tool where everything else had been drawn. Same surface ramp, same corner
radius, and a tick drawn with clip-path so it can't pick up a substituted font.

uiAudit gains "an open dialog's buttons are on screen", which hit-tests rather
than trusting geometry — a button can be inside the viewport and still be under
something. Fault-injected: putting the footer back inside the scrolling body
reports Confirm 105px below the fold.

Two things checked and left alone, because they turned out to be right: the
standings table hides three columns on narrow screens through a container query
keyed to the sidebar rather than the viewport, which is why a desktop shows
fewer columns than an iPad — the sidebar is the narrower box there. And In/Won/
Net read $0 for hands settled outside the wizard, which the comment above them
already explains.

Caught by my own guard on the way past: the token test read `scroll-padding` as
`padding` and failed the spacing scale. Correct by its rules, wrong in substance
— those values are a sticky bar's measured height, not a step in the rhythm — so
the lookbehind now rejects a hyphen as well as a word character. Re-fault-
injected to confirm it still catches a real ad-hoc gap.

619 tests, 0 failures. 65 layout audits across 13 viewports x 5 table sizes and
35 across the five overlays. The whole wizard walked end to end: side pot
resolved, hand logged, $1,560 on the table before and after.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-03 12:20:02 -06:00
jheapsandClaude Opus 5 1f3dda180f Make the table big enough for the game around it
Looked at the felt at three times scale and the seats were falling off it. Not
sitting at a rail — hanging past the outer edge of the table into the page,
half a puck on green and half on nothing. Measured:

  1280x900, nine-handed   five of nine seats past the rail, worst 49px
  844x390,  six-handed    all six, worst 20px
  430x932,  six-handed    two, worst 12px
  360x640,  nine-handed   none

The ring sat at a flat 44% by 42% of the seat layer at every size, two numbers
picked against a phone. A fixed pair can only be right for one ratio of puck to
felt, and the felt's ratio changes with every viewport — so it was a different
amount of wrong on each one, which is exactly what makes something look like a
mistake rather than a choice.

The radius is derived now: the widest ring whose outermost seat still finishes
inside the table, worked out from the measured table, seat layer and puck. It
can only ever pull seats in — the old pair is kept as the ceiling — and it falls
back to that pair whenever there is nothing to measure, which is every call from
the unit tests and the first render before the felt is laid out.

Three things this turned up on the way, each caught by the sweep:

  * The seat layer is not centred on the table. Its insets differ top from
    bottom, so the first version — which assumed symmetry and worked from half
    widths — left a phone with 12px still over the rail. It works in absolute
    pixels against the nearer edge now.

  * The radius and the puck size depend on each other: the ring is limited by
    how big a puck is, the puck is sized by the `seatsTight` class, and that
    class was decided from the gaps between seats, measured at a radius that
    hadn't been worked out yet. Fixing the gap measurement then made a
    nine-handed phone overhang that had been fine, because the two were chasing
    each other. It is settled by trying the candidates in preference order
    instead: the roomy layout if everything fits, the tight one if not, and if
    neither fits, tight pucks on a ring widened only as far as it takes to stop
    seats colliding — because two seats fighting over a tap is a worse failure
    than a puck over the rail. A dealer can see an overhang; they cannot see
    that the seat they pressed wasn't the one that answered.

  * The spacing threshold was a flat 84px, the figure a 96px puck was judged
    against. Asking a 68px tight puck to keep a 96px puck's distance is a test
    it can never pass, so every landscape table fell through to the fallback and
    took an overhang it didn't need. It is a ratio now, not a number.

Result: worst overhang anywhere goes 49px to 9px, and 59 of 65 configurations
are completely clean. Desktop, tablet and every phone held upright: nothing over
the rail at all.

Residual, reported rather than tuned away: six configurations still overhang by
1-9px, all of them landscape at 640x360 and 667x375, where the felt is about
110-125px tall and no ring puts readable pucks inside that. The audit check is
left strict so they stay visible.

uiAudit gains "seats and chips stay on the table", which runs at every viewport
in the sweep. Fault-injected: putting the flat 44/42 back reproduces the original
failure exactly, including seat 3 at 49px.

Faster, too, because the measuring got hoisted out of the per-seat loops while I
was in there:

  renderSeats   7.3ms -> 1.8ms
  renderAll    12.4ms -> 6.8ms

619 tests, 0 failures. 65 layout audits across 13 viewports x 5 table sizes.
Offline re-verified: a whole hand with the network cut, $800 conserved.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-03 11:55:51 -06:00
jheapsandClaude Opus 5 3ffce9d7c1 Stop the empty table shouting, and point at the next step
The first screen a dealer ever sees was the one nobody had looked at. Opened
fresh, with nothing saved, the tool showed:

  * the word EMPTY, in capitals, ten times across the felt
  * Resolve Pot as the accent-green primary — on a table with no players and no
    pot, so the loudest control on screen was the one that could do least
  * Next Hand, with no button to move
  * no indication anywhere of what to do first, except a grey caption under the
    felt saying "tap a seat to edit"

An empty state is the one screen a new user is guaranteed to meet, and this one
described itself ten times without once saying what to do.

**The felt stops repeating itself.** An open chair is already dashed instead of
solid, faded, and has no stack under it — that reads at a glance, which is how a
dealer looks at a felt, and it reads the same on the one open chair at a full
table, which is the case the label was actually for. The word is gone from all
ten, and the seat numbers underneath it are now the thing you see. What the word
carried for anyone reading by ear moves to an aria-label on the seat, which now
says more than the sighted version ever did: "Seat 3, empty", "Seat 1, Ann".

**The bar says what the table is.** It used to hide itself when nobody was
seated — silent at exactly the moment a dealer has never used this before. It
now says "Nobody's at the table yet." with one full-width button, Seat a player,
which opens the seat editor on the first free chair. That is the same panel
tapping a seat gives them, so the button teaches the gesture rather than
replacing it.

**Two dead controls stop pretending.** Resolve Pot and Next Hand are disabled
until somebody is at the table, which takes the accent green off the button that
cannot work and leaves the only green on screen on the one that can. Disabled
rather than hidden: hiding them would move the header the moment the first
player is named, and a control that vanishes teaches nothing about what it was
for. Next Hand's own explanatory title is kept and handed back when it
re-enables.

One bug of my own, found by walking the flow rather than by reading it: after
typing a name both header buttons stayed greyed out, still captioned "nobody is
at the table yet". commitSeatChange refreshes narrowly on purpose — a full
render on every keystroke takes the name field out from under the dealer
mid-word — and the comment directly above it already warns about this exact trap
for the action bar. The header needed the same line.

609 tests, 0 failures. 65 layout audits across 13 viewports x 5 table sizes,
0 failures — the empty table is now one of those sizes, and had never been
audited before. Re-verified with the network cut: the first-run screen, the CTA
and seating a player all work offline.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-03 11:14:30 -06:00
jheapsandClaude Opus 5 5d5093f3cc Make the dealer tool work with no signal, and stop it shouting
Three findings this round, all measured on the running page.

**1. The tool needed the internet to exist.**

The night already survived anything — it lives in localStorage — but the page
did not. Reload in somebody's back room with one bar of signal and a dealer
holding a phone in one hand and a deck in the other got a browser error page
with nine players waiting. Verified: with no worker, an offline reload is
net::ERR_INTERNET_DISCONNECTED.

There is now a service worker (wwwroot/casino/sw.js) and a manifest, so the
tool installs to a home screen and runs with the network switched off. Measured
end to end: 19 assets cached from one ordinary visit, then with the context
offline the page loads, draws six seats, applies its CSS, and plays a whole
hand — blinds posted, four calls, $800 still on the table.

Three decisions worth writing down:

  * The precache list is read off the document rather than kept in the worker.
    Every stylesheet and script is already in the DOM; a hand-written file list
    stays right until somebody adds a script tag, and then the tool is quietly
    offline-broken until a dealer finds out at a table.
  * No skipWaiting. A new worker taking over a live page can hand it assets
    from a different release than the ones it started with, and this page loads
    seventeen scripts that expect to agree with each other.
  * Stale-while-revalidate for assets, not cache-first — see finding 3.

Scope is /casino/ rather than /casino/texasholdem/, because a scope has to be a
prefix of the page it controls and "/casino/texasholdem" does not start with
"/casino/texasholdem/". Anything in scope the worker wasn't told about goes
straight to the network.

Program.cs needed .webmanifest added to the MIME map: an unknown extension is
not served with the wrong type, it is not served at all, and a 404 manifest is
exactly the silent failure that stops a browser ever offering "Add to Home
Screen".

**2. A screen reader read out the whole action bar after every tap.**

The bar carried aria-live on the element whose entire contents render() rebuilds
each action. Measured, after one tap:

  "PRE-FLOP Pot $15 9 players Bob · $250 behind 0:00 ↶ dealing the hand U
   Fold Call $10 Raise"

Nothing in it is wrong; it is unusable at the speed a hand is dealt, and it is
the documented anti-pattern — a live region has to be a stable element whose
text changes, not a container that gets replaced. There is now a small
role="status" element that exists from page load and says one sentence:
"Dee to act, $10 to call. Pot $15." Guarded on the sentence rather than on the
state, because render() runs for many reasons that don't change whose turn it
is, and a live region handed the same words twice reads them twice.

**3. Every tap rebuilt ninety DOM nodes.**

renderSeats did `innerHTML = ""` and then cloned a template, attached three sets
of listeners and set a dozen attributes per chair — on every render, and
renderAll runs after every action. Profiled on a nine-handed table:

  renderSeats   7.3ms -> 4.0ms
  renderAll    12.4ms -> 9.5ms

The pucks are built once and updated in place, which is what the rest of that
function was already doing to their contents. A seat's node belongs to its index
for as long as the table is that size, so the listeners still close over the
index; only a change in the number of chairs rebuilds the row. Verified that
listeners don't stack: one click after six renders fires the hook exactly once,
and resizing 9 -> 4 -> 10 renumbers correctly.

Found along the way and deliberately left alone: ViewData["cssVersion"] renders
empty on every page of the site, so the ?v= cache-busting has never worked. It
is inert — Program.cs sends Cache-Control: no-store on all static files — and
the service worker was the only reason to care, which is why it uses
stale-while-revalidate instead. Noted in TexasHoldem.cshtml.

608 tests, 0 failures, including a new announcing.test.js. 52 layout audits
across 13 viewports x 4 table sizes: 0 failures, and the action bar still
doesn't change height mid-hand. Gestures re-verified after the seat refactor:
double-tap fold, long-press menu, give-the-button, and the active dealer chip.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-03 10:51:27 -06:00
jheaps aaf96543a8 Merge pull request 'Hold'em dealer: blind fix, design tokens, and the decrowd' (#116) from fix/naming-a-blind-deals-the-hand into master
Build and Deploy / build-deploy (push) Successful in 1m36s
Reviewed-on: #116
2026-09-03 10:26:50 -06:00
jheapsandClaude Opus 5 2e9c55af21 Give the felt back the screen the chrome was using
Measured on a 390x844 phone, six-handed, same table before and after:

                        before   after
  header                  193px    120px
  felt starts at          225px    152px
  controls on screen         27       18
  bordered elements          37       22
  text elements              61       49

A quarter of the screen went on chrome before the dealer saw the table.

**The header.** Six equally weighted buttons in two rows, of which two are
per-hand controls. The other four have gone where they belong rather than into
a "more" menu — that relocates crowding instead of removing it. Reset Table is
in the Session panel now, beside Clear history and the save/load pair: it is
the control that throws the night away and it was sitting in the same weight as
Resolve Pot, the most-pressed button in a session. Quick Split is down with
Common rulings, where the other "open something else" link already lived. The
way back to the site is a quiet link at the top, where a back link goes.

The subtitle is gone. It was one sentence of orientation on a tool used
nightly, and the landscape and short-phone rules already hid it — the layout
had decided it was expendable on every screen this is actually used on.

**Teaching copy, once.** Table Setup shipped open, so the first screen was the
most crowded one; it now opens only on a table nobody has set up yet, decided
once at load so it cannot reopen itself under a dealer who just shut it. The
gesture hint under the felt goes after the first deal of the session — keyed to
the hand number, not to bettingOpen, which was the obvious test and the wrong
one: it would have appeared and disappeared twice a hand, shunting the panel
stack up and down all night.

**Borders.** Panels had a border, a lighter background and a shadow — three
ways of drawing one edge. The standings had a rule under every row, which is
what makes a list read as ruled paper. Both drop the line and keep the
grouping; the standings header keeps its rule, because that one marks a change
of kind. Fields and buttons keep theirs: those say "interactive", which is not
decoration.

**The felt itself.** Names wrap to two lines instead of ellipsising, so
"Christopher" and "Mike from work" are readable rather than "Chri..." and "Mike
fro..." — the truncation was eating exactly the half a dealer typed to tell two
Mikes apart. And a stack of $1,234,567 rendered at 7px; it now drops the
thousands separators before it drops the type size, so the same number is eight
characters at 10px with every digit intact. The comment above fitAmount was
right that abbreviating loses precision — but precision the dealer cannot read
is not precision.

Two things tried and reverted, both measured:
  * Reserving the second name line made every puck the height of the tallest
    (69-73px became a uniform 88). Fifteen pixels on all nine seats is felt
    spent on chrome, which is what this round is trying to give back.
  * Dropping the name a type step to fit more on one line still did not fit
    "Christopher" on the button seat, where the dealer chip takes 22px of the
    row — so it cost legibility and bought nothing.

Known limit: a single word too long for a puck breaks across the two lines, and
gets a hyphen only where the browser has hyphenation dictionaries. Real phones
do; headless Chromium does not, so audit screenshots show "Christo / pher". The
whole name is visible either way, which it was not before.

599 tests, 0 failures. 52 layout audits across 13 viewports x 4 table sizes
(2, 6, 9, 12 seats) and 20 across the five overlays: 0 failures, and the action
bar still does not change height mid-hand at any of them. The back link was
caught by the audit at 56x13 when it stopped being a button; its hit area is
44px now, paid for with negative margins so the header keeps the 73px.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-03 10:17:08 -06:00
jheapsandClaude Opus 5 8b43614b1c Give the stylesheet a vocabulary, and stop the bar moving
The tool read as crowded on screens where nothing overlapped, and the reason
turned out to be arithmetic rather than taste: holdem.css used 42 different
spacing values, 31 font sizes, 14 border-radii and 139 colours, and defined
three custom properties. Every number had been picked by hand at the moment it
was needed. A 0.42rem gap beside a 0.45rem gap beside a 0.5rem gap gives the
eye no rhythm to lock onto, so it reads the whole surface as noise.

So: constrained scales, and everything snapped to them. Seven spacing steps on
an 8px rhythm, eight type sizes, four corners, and a four-step surface ramp
that stays off pure black — shadows are invisible on #000 and white-on-black
smears on the OLED phone this is used on. 661 values rewritten; colour mentions
halved, 381 to 174. Nothing here changes what the tool does.

Two things were measurably moving under the dealer's thumb, and both are fixed
by reserving space rather than by animating anything:

  * The undo pill named the last action, so it resized on every single tap.
    Measured across one hand: 148, 110, 109, 108, 109, 110, 120px. It is now a
    fixed 10rem, with the label in its own element so it can ellipsize without
    truncating through the keycap beside it.

  * The action bar is fixed to the bottom, so anything that changes its height
    moves its top edge. The status row wraps, and it wrapped differently as the
    street and pot changed. It now holds two lines' worth of room whenever a
    hand is running. Across 13 viewports x 3 table sizes, the bar no longer
    changes height mid-hand at all.

Numbers that change while the dealer watches — pot, stacks, bets, standings —
are now set in tabular figures, so a pot ticking $18 to $21 stops shuffling its
digits sideways.

Two regressions the sweeps caught on the way, both from the snapping:
  * The Add Chips field cleared 44px by way of its font size plus its padding,
    so moving either dropped it to 42. It states its own min-height now; a
    touch target shouldn't be an accident of two other numbers.
  * The bar came out 2px shorter in landscape, which grew the felt, which
    lifted the top seat's dealer button 2px into the header. That clearance was
    structurally 1-7px, so the top seat inset became a floor rather than a
    ceiling instead of chasing the 2px.

tests/holdem/designTokens.test.js keeps the scales honest — a new value has to
be a step or an exception with a reason. Fault-injected four ways.

599 tests, 0 failures. 39 layout audits across 13 viewports and 3 table sizes,
plus 35 across the five overlays: 0 failures.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-03 03:11:13 -06:00
jheapsandClaude Opus 5 42b9e83e8f Don't deal the hand when the dealer names a blind
The seat menu's "Make small blind" / "Make big blind" / "Blinds follow the
button again" all re-posted the blinds whenever `Betting.onlyBlindsIn(state)`
was true. That condition means "nothing has happened in this hand except the
blinds" — which is true of a hand one second old and equally true of a table
sitting in setup with no cards out and nothing committed.

So naming a blind before the deal ran repostBlinds -> beginHand: betting
opened, two stacks docked, the action bar up and waiting on the seat after the
big blind. The dealer had said where the blinds go and the tool heard "deal".

Betting.blindsArePosted() is the question these three actually wanted — the
blinds are down only once a hand is open — and it's the guard makeDealer was
already using by hand. Before the deal, naming a blind now moves a badge and
nothing else; the toast says "Dee has the small blind next hand." rather than
claiming she posted it. Mid-hand behaviour is unchanged: chips come back and
go in again from the new positions, and the chip count stays straight.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 11:11:34 -06:00
jheaps 419c1de2a0 Merge pull request 'Hold'em dealer: 55 polish rounds, one merge' (#101) from release/casino-holdem-polish into master
Build and Deploy / build-deploy (push) Successful in 1m32s
Reviewed-on: #101
2026-09-02 10:00:14 -06:00
jheaps 470fbc0d26 Add the seat-name round to the release 2026-09-02 09:52:35 -06:00
jheapsandClaude Opus 5 c4e842f3e3 Stop the button's chip sitting on top of the name underneath it
A phone screenshot, mid-hand, eight-handed: the seat with the button reads
"Christop" and then a white D. The name is centred across the whole puck and the
chip sits in its top-right corner on a layer above, so a long name runs
underneath and stops — measured, "Christopher" lost 20px of its 64px box.

The bad part is the silence. The text believed it had the room it was being
painted out of, so no ellipsis appeared: nothing on screen said the name went on.
On the one seat at the table a dealer looks at most.

The name now keeps out of the chip's corner on the seat that has the button, so a
long one ends in an ellipsis before it gets there. Only that seat — every other
puck has its full width, because the chip on those is invisible.

And the audit has a check for it now. Nothing it had would have caught this: the
occlusion check is about controls a thumb has to reach, and this is a label a
dealer has to read. The new one measures every seat's name against its own chip
and badges, and the fix was undone by hand to confirm it fires — "20px under
button.dealerChip.active".

182 audits across 13 viewports, both input kinds, plain and straddled tables,
every overlay: clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 09:52:00 -06:00
jheaps 1d9353c94e Add the hidden-means-hidden round to the release 2026-09-02 09:37:29 -06:00
jheapsandClaude Opus 5 8616636212 Make hidden mean hidden, and check that it does
Three rounds inside the money, so this one was a look at the thing. On a desktop
screenshot of a table mid-session there is a small empty box under the bank
check: 21 pixels wide, 44 tall, no text, no purpose. It is the "Take $600 from 3
players" button from three rounds ago, which sets `hidden` when there is nothing
to record — and `hidden` was losing.

The attribute carries its `display: none` from the browser's own stylesheet,
which is the weakest rule in the cascade, so any class that sets a display beats
it. Every button class here sets `inline-flex`. This is not a new discovery in
this file either: seven overlays already carry their own `[hidden] { display:
none }`, each one presumably found the same way. One global rule now covers all
of them, so there is no ninth.

The audit had been telling me. Its touch check reported the box as "could not be
probed (drawn 21x44)" — in the warnings, which my sweeps printed only sometimes
and never failed on. So the check now says the plainer thing: an element that
claims to be hidden and is drawn anyway is a fault, not a note. It is in runAll
with the rest, and the bug was put back by hand to confirm it fires.

The same warning stream had a second thing in it: the waiting list's Add button
was 43px wide. Three letters, one pixel under the rule, and a thumb doesn't know
the word is short.

182 audits across 13 viewports, both input kinds, every overlay and a table with
a rake, a cash-out, a waiting list and six logged hands on it: clean, and the
only note left is the one that says the 44px rule stood itself down on a mouse.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 09:36:51 -06:00
jheaps 6174981cdc Add the assumed-contributions round to the release 2026-09-02 09:21:57 -06:00
jheapsandClaude Opus 5 4db6f6e4d3 Take the chips the pot was priced with, not just pay them out
Two rounds of asking where the tool disagrees with itself, and here is the last
of it — the one that was actually creating money.

There are two ways a contribution gets in. Tapping the action moves chips: the
bet leaves the stack, sits in front of the player, and is swept into their hand
total. The other is the "without live betting" panel — a base pot amount, or
chips counted out on a seat — for a dealer recording a hand they are watching
rather than driving. Nothing leaves anybody's stack there.

The pot wizard prices both the same way, and awarding it credits the winner. So
in the second mode the table simply grew. Measured at a three-handed table with a
$50 base pot: stacks 200, 200, 200 before, pot priced at $150, and afterwards
350, 200, 200 — six hundred dollars of chips became seven hundred and fifty, and
the bank check, newly able to see it, blamed a rebuy nobody had made.

So before the pot is pushed, whatever was assumed rather than taken comes off the
stacks that assumed it, and is recorded where a swept bet lives. Chips already
committed or swept are left alone — they have been paid once, and taking them
twice is the same bug the other way round. A base pot on top of a live bet
collects only the difference. Nobody is charged more than they are holding, and
an empty chair or a seat sitting out is charged nothing at all.

The same hand now ends 300, 150, 150: the table has the six hundred it started
with, and the bank check is balanced before, during and after.

13 new tests, with the already-paid test disabled on purpose to confirm five of
them catch the double charge. Walked through the UI in base-pot mode: pot badge
$150, wizard pricing three seats at $50 each, and afterwards the felt holding
exactly what it held before.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 09:21:19 -06:00
jheaps 0ad5592687 Add the one-stack-rule round to the release 2026-09-02 09:06:07 -06:00
jheapsandClaude Opus 5 1c2e716508 Ask the same two questions the same way everywhere
The round before this found the bank check and the standings reading a seat's own
stack where the felt reads the table's starting stack — two parts of the tool
disagreeing about how much money was on the table. That was worth a sweep, and
the sweep found more.

The colour-up panel had the same fault. It is the panel that says "count out
2×$100 for this player", and on a table opened the ordinary way — a starting
stack and some names — it showed nothing at all. The one table where every stack
is a round number made of the largest chips there are was the table it had
nothing to say about.

The other question, "is anybody in this chair", existed in four spellings: in the
seat count, in the seat menu twice, in the waiting list, and twice more in the
state module. Three of them looked at name, chips and cash; one also looked at a
hand being dealt. A chair that counts as occupied for one and empty for another
is how somebody's chips get given away — measured, a seat marked in hand with no
name and no stack yet was on offer to the next person in the waiting queue, on
top of whoever was already playing it.

Both questions now have one implementation and one name:

    "What is this seat holding?"   HoldemState.chipsHeldBy
    "Is anybody in this chair?"    HoldemState.seatInPlay

10 new tests, including one that pins the bank check, the standings and the
settle-up plan to the same total, and one that pins the seat count and the open
seats to the same set of empty chairs. The occupancy test was cut back to its old
three-part form on purpose to confirm the waiting-list case fails.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 09:05:32 -06:00
jheaps 236c52567e Add the starting-stack accounting round to the release 2026-09-02 08:53:35 -06:00
jheapsandClaude Opus 5 1689ecb4f0 Count the chips the table is actually playing with
"Starting stack" is how a night opens. The setup panel offers it and says what it
means — "seats without their own stack start here" — and everything a dealer
looks at reads it: the puck, the action bar, what a player is allowed to bet.

The money side didn't. The bank check and the standings read `seat.stack`
directly, which is null for exactly those seats. Measured on a three-handed table
with a $200 starting stack and nobody's own stack typed in: the felt played for
$600, the bank check reported no chips in play and pronounced itself balanced,
and the standings — the list a dealer settles the night from — listed nobody at
all. Three players, six hundred dollars, and the tool's own accounting said the
table was empty.

Both now ask the same question the felt asks. The check reports $600 in play
against nothing taken, which is the truth: those chips have not been paid for.
Empty chairs are excluded, or the starting stack would make every one of them
look like somebody holding two hundred dollars — a ten-seat table would have
opened with $2,000 of imaginary chips.

Being told is only half of it. The verdict names the seats rather than saying
"somebody's rebuy probably wasn't recorded", because the dealer's next move is to
go to a particular chair and the tool knows which. And the seats holding the
table's starting stack — the dealer's own instruction, said once in the setup
panel — can be written down in one tap instead of six trips through Add chips.
Only those: what somebody's forgotten rebuy was is a question only the dealer can
answer, and guessing it would be the tool inventing money, which is the fault
this round is about.

14 new tests, with the stack source and the empty-chair test broken on purpose to
confirm they fail. Walked through in the browser from a cleared table: set the
starting stack, name three seats, and the panel goes from "balanced, no chips" to
"$600 more in chips than cash taken. Ann, Ben and Cal are holding chips with no
buy-in written down" — then to balanced, with the buy-ins recorded and undoable.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 08:52:56 -06:00
jheaps b8655be258 Add the waiting-list round to the release 2026-09-02 08:38:21 -06:00
jheapsandClaude Opus 5 55eb569d3b Keep the list of who's next, instead of leaving it on a napkin
A full home game always has two or three people standing behind the chairs, and
the list of who's next lives on a napkin or in the dealer's head. That is where
the argument an hour later comes from. The tool had just learned to open a chair
— somebody cashes out and the seat is free — and had nothing to say about who
should sit in it.

The Session panel keeps the list in order of arrival, which is the only ordering
anybody has ever agreed to. Whoever is at the front is offered the first open
chair by number, and everybody else waits their turn: a list that isn't kept in
order is a pile of names, and jumping the queue is the thing a dealer gets
shouted at for. With the table full it says so rather than offering a seat that
doesn't exist. The same offer appears on the empty chair's own menu — "Seat
Priya" — because tapping the chair somebody is about to sit in is what a dealer's
hand does anyway.

Sitting somebody down puts their name on the chair and nothing else. No stack, no
buy-in, not dealt in: they haven't paid yet, and inventing chips for them would
put money on the table nobody has handed over, which is the one thing the bank
check exists to catch. Add chips is the next thing the dealer does, and it is one
tap away in the same menu.

Two people called Mike are two people — the list is keyed by id, not by name.
Names are trimmed, blanks are refused, and a saved list from a table that never
had one comes back empty rather than undefined.

15 new tests, with the occupied-chair guard removed and a stack handed to the new
player on purpose to confirm they fail. Walked through the browser: two names
added to a full table, a player cashed out to open seat 2, the front of the queue
seated there with nothing in front of them, the bank still balanced, and the
second name taken off the list. 156 audits with the list populated, including a
name long enough to test the row.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 08:37:44 -06:00
jheaps 7e420ce747 Add the misdeal round to the release 2026-09-02 08:24:05 -06:00
jheapsandClaude Opus 5 86886ac126 Take a hand back, which the rulings sheet has been asking for all along
This tool has told dealers to redeal on a misdeal since the rulings sheet was
written — first card to the wrong seat, a seat entitled to a hand dealt out, one
of the first two cards exposed — and it had no way to do it. The only control
that ended a live hand was Next Hand, which says out loud that it takes the money
off the table without paying anybody. That is the right answer for an abandoned
pot and the wrong one for a misdeal, where the blinds and the calls belong to the
people who put them in.

Undo covers the first second of it: "↶ dealing the hand" is one tap. But the
action history lives in memory, so a dealer who is three calls in, or who has
refreshed the page mid-hand, has nothing — and a refresh mid-hand is exactly the
situation this tool has spent rounds making survivable everywhere else.

Misdeal appears in the header while a hand is out and goes away when it isn't:
before the deal there is nothing to take back, and afterwards it would be
offering to unwind a hand that has already been paid. It says what it will do to
the money — "$25 goes back to the players who put it in, cards are cleared, and
the same button deals again" — and adds a note when two or more players have
already acted, because most rooms let a deal stand at that point. It doesn't
refuse: which conditions make a misdeal is the dealer's call and the sheet is
right there.

What comes back is everything, including chips already swept into hand totals on
an earlier street — the first version returned only the current street's and
would have quietly kept a flop's betting. What stays is the button, the blinds,
the straddle and anyone posting in: a misdeal is the same hand dealt again, not
the next one, so the hand number doesn't move and nothing is logged.

13 new tests, with the sweep and the straddle broken on purpose to confirm they
catch it. Measured through the UI: pot $25 with three players in, take it back,
every stack is exactly $200 again, the bank still balances, and dealing again
posts the same blinds on the same players from the same button. 156 audits with
the button live, including a landscape header that stays one row at 46px.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 08:23:27 -06:00
jheaps c36d7f60bd Add the cash-out round to the release 2026-09-02 08:09:26 -06:00
jheapsandClaude Opus 5 f966d8c340 Pay somebody out and keep the chair, the cash and the record straight
The most ordinary thing that happens at a home game after midnight — somebody
counts up and goes home — and the tool had nothing for it. Freeing the chair
meant wiping the seat by hand, and the bank check then declared their whole stack
missing: "someone has paid for chips they haven't been given", for the rest of
the night. Measured on a balanced table, cashing a player out with $350 in front
of them left the check $350 short. Wiping their buy-in as well silenced it, at
the price of losing their entire night from the standings and from the file the
dealer hands over at the end.

Cashing out is two facts, and both have to survive the empty chair: the box took
their cash, and the box handed their chips back. Both are kept now, so the chair
opens and the money still adds up.

Cash out… is in the seat menu. It says what to count — "$350 — 3×$100, 2×$25" —
what they bought in for, and whether they leave up or down, because that is the
sentence the dealer says while their hands are moving. The bank check gains a
Paid out line and reads "balanced ... once the $350 paid out to players who have
left is counted", alongside the discards and the house's cut. The standings keep
them with a stripe and the words "· paid out", so nobody counts money out to them
twice at 2am, and the export marks them the same way.

It refuses to pay anybody with money still in the middle: taking chips out of a
pot other people can win, or handing the same chips over twice, are both worse
than waiting for the hand to finish. The chair gets a fresh id, because the
person who had it has gone and the next player to sit down is not them.

Two things this turned up. The first draft worked out what a seat was holding by
itself, read `Number(null)` as a perfectly good zero, and offered to pay a player
with no stack set nothing at all — it asks Betting now, which owns that rule.
The second was mine and the audit caught it: marking the departed row with
opacity took the In and Won cells to 3.83:1, under AA, on the very list a dealer
settles from. It is a stripe instead.

19 new tests, with the departed buy-in dropped from the check and the pot guard
disabled on purpose to confirm they fail. Walked through in the browser: menu,
confirm, payout, bank, standings, undo, and the refusal.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 08:08:46 -06:00
jheaps 15cec18256 Add the clock round to the release 2026-09-02 05:40:45 -06:00
jheapsandClaude Opus 5 0e2821257f Run the clock on a player, instead of counting in your head
The bar has had a stopwatch on the acting seat for a while: how long this player
has been thinking, so the dealer knows when it's worth a nudge. What it never had
was the thing a dealer actually does about it. Calling the clock is a procedure —
a player asks, the dealer grants it, the player gets a set time and then a
countdown said out loud, and if they still haven't acted the hand is decided for
them. Running that from this tool meant counting in your head while the table
argued about whether it had been thirty seconds yet.

Thirty and then ten, which is the common form and what the TDA writes down. The
countdown is the part the table hears, so it is marked apart rather than being
ten more seconds of the same: the readout switches to a bare number and turns
red. At zero the bar says the ruling in the dealer's own words and offers it as
one button.

The ruling depends on what they were facing, and getting that backwards is the
part worth being careful about. Facing a bet, the hand is dead. Facing nothing,
it is checked — nobody gets folded for being slow when checking was free.

Three things about where it sits. The button appears only once somebody has been
thinking long enough for the table to start asking, which is the same threshold
the stopwatch already marks, so an ordinary hand costs no room at all: measured
at 390x844, the bar is 152px with or without it. It is 44px like everything else
a thumb has to find — it was drawn at 36 and the audit called it at every touch
viewport the moment the check ran, which is the rule doing its job on a control
that kills a hand. And once the count is over the Stop button goes, because there
is nothing left to stop: at 640x360 its 94px pushed the undo control onto a
second line and the taller bar covered the two bottom seats.

The ordinary Fold/Check/Raise stay underneath the ruling. A player who speaks up
as the count ends is still acting, and whether it was in time is the dealer's
call, not the tool's.

11 new tests, and both halves of the ruling were inverted on purpose to check
they catch it. Watched end to end in the browser at shortened intervals: the
button appears, the count runs down, the ruling lands, and the hand is killed —
or checked — with the undo control naming what happened.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 05:40:06 -06:00
jheaps 9c8d981e2d Add the house-take round to the release 2026-09-02 05:13:11 -06:00
jheapsandClaude Opus 5 c19575ab10 Count the house's cut instead of calling it missing chips
A game that takes a rake has chips leaving the table every hand, and the bank
check had no idea. It added up the buy-ins, added up the chips on the felt, and
told the dealer somebody had been paid for chips they were never given — a
shortfall that grows all night and points at a miscount that never happened.
Every home game that takes a drop for the beer fund hits this on hand one.

So the tool takes it, rather than watching it go. Under Table Setup: a
percentage, an optional cap, and "no flop, no drop" — the rule almost everyone
plays, on by default, where a hand that ends before the flop pays nothing. Zero
percent is a game that doesn't rake, which is most of them and the default, and
nothing about that game changes.

Where it shows up is the point. The pot wizard says "House takes $2 of $40 — 5%,
capped at $5" above the payouts, before a chip moves, because the summary is the
dealer's last chance to disagree with the arithmetic and money taken quietly is
the one thing a tool like this must never do. The bank check gets a House take
line and says "balanced ... once the $2 in the house's cut is counted". The hand
log keeps the pot as the table saw it with the cut recorded beside it, and the
export has a column for it, so the night's total is one sum in a spreadsheet.

Two decisions worth stating. The rake rounds *down* to a chip the table actually
has: a house that can't make 40c takes nothing and the players keep it, which is
the only way to be wrong about this that nobody argues with. And on a split pot
it comes off the top, then what is left is shared in proportion — with the
rounding leftovers going back to the winners rather than to the house, which
asked for a number and gets that number, not that number plus dust.

Measured end to end through the UI: 5% capped at $5 on a $40 pot pays $38 and
banks $2; four stacks plus the cut come to exactly the $800 bought in; the bank
check reads balanced. With no-flop-no-drop on, a hand folded round pre-flop pays
the full $15 and takes nothing; with it off the same hand pays $14 and takes $1.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 05:12:33 -06:00
jheaps 907e0165db Add the stadium ring round to the release 2026-09-02 04:59:37 -06:00
jheapsandClaude Opus 5 8aa90e90a4 Give a phone on its side the table shape it needs
The last two rounds left one thing failing and said so: a landscape phone with
six or twelve seats stacked two of them 25px apart at each end of the felt, with
pucks 64px tall. It has been there since seats were first spaced by distance
rather than by angle. Three things were wrong at once, and it needed all three.

The rim is the wrong shape when the table is very flat. An ellipse 616px wide and
96px tall has nowhere to put the seats near its ends: the curve doubles back on
itself, so two seats a long walk apart come out one above the other. Past two and
a half times wider than tall the rim is now a stadium — two straight rails with a
half-round cap at each end, which is how a real poker table is built and exactly
why it is built that way. A cap is a half-circle of the felt's own height, so an
end takes one seat and the rest is rail. Only past that ratio: measured, the
cut-off lands between a landscape phone at 4.4 and every other layout the tool
has, 1.45 at 1280x900 and 1.0 to 1.4 upright, so no working table changes shape.

The ring had less height than the felt did. The seat layer is inset by half a
puck to keep the outermost seats on screen, and 26px off the top and bottom of a
96px felt left 44px to work with — less than half the table, which is why no rim
shape could have fixed it alone. The inset is a percentage where the felt is
short. The landscape branch never had that rule at all, because it is matched on
height while the inset lives in a width query, so an 844px-wide phone on its side
drew its ring against the felt's whole box; with a flat-topped stadium rail that
put a dealer button over Resolve Pot in the header.

And the puck was a line too tall. In the tight state the hole cards give up their
row: of the four things on a puck, the cards are the one the dealer is holding in
their other hand, and the seat editor still shows them. That took the puck from
64px to 55px, which is what finally cleared the ends.

Measured across 36 combinations of nine viewports and 2, 6, 9 and 12 seats: the
closest two seats at 640x360 go from 25px apart to 55px, and every combination is
clean. Then 216 audits across thirteen viewports, three seat counts and both a
mouse and a thumb: no failures, no console errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 04:58:59 -06:00
jheaps 86e1570618 Add the phone felt round to the release 2026-09-02 04:34:05 -06:00
jheapsandClaude Opus 5 dc5db16ffd Let the phone table use the width it has
Last round capped the felt so the ring would clear the action bar, and capped the
wrong dimension. The wide layout sizes the felt from its width, so capping the
width is how you shorten it there — on a phone the felt is already the full width
of the column, and capping that threw away 96px of a 360px screen: a 240x240
table inside a 336px space, with nine pucks crowding it.

A max-height instead leaves the width alone and lets the aspect ratio give way,
which turns a short screen into a wide oval — the shape a poker table is. At
360x640: 336x240 rather than 240x240, same clearance over the bar, and the
twelve-seat ring that was crowded at 240 comes out clean, which closes the one
combination left failing last round. Where the room isn't the binding constraint,
390x844 and up, nothing changes and the table stays round. The landscape branch
now says max-height: none, because it already spends the same measured room on
its own height and applying it twice took the felt to the floor value.

The measurement that decides whether pucks shrink was also wrong, in a way that
only showed up once it was asked a hard question. Rim length per seat measures
the walk between two seats, not the gap between them, and on a long oval the rim
doubles back at the ends: twelve seats at 640x360 had 193px of rim each and sat
60px apart. It now measures the closest two neighbours directly, off the ring
itself — and off the seat layer rather than the felt, because the layer is inset
by half a puck on a phone and the felt's box put the answer out by 30px.

That makes the rule geometric rather than about screen size, so it is no longer
scoped to a phone media query: a wide landscape felt crowds at its ends while its
rim claims there is room, and now it is told so.

Pucks still touch at nine and twelve seats on a small screen. Tried 46px against
58px: on a roomy phone 46 cleared every overlap where 58 left two, but in a hand
with a side pot, where the bar has grown and the felt has shrunk to suit, 46
overlapped four times and 58 twice. That is the felt being small, not the puck
being wide, and paying for it with an ellipsis on every long name buys nothing.
What has to hold is that each seat keeps its own centre, and that is measured at
every size.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 04:33:25 -06:00
jheaps 6df400322e Add the audit round to the release 2026-09-02 04:03:25 -06:00
jheapsandClaude Opus 5 b9b2237dbe Run the two checks the audit had and never used
runAll() did not include the contrast check — it simply wasn't in the list — and
touch targets sat behind an opt-in flag nothing passed. So "the audit is clean"
has meant nine checks rather than eleven for a long run of rounds, and the two
left out are the two whose faults a dealer meets in a dim room with their thumb.
Both were written, tested, and then never called; the contrast checker was built
across two rounds and every sweep since has been silent about colour.

The standalone runner had a second version of the same hole: the 44px rule
stands itself down where `hover: hover` says there's a pointer, and a headless
desktop browser is a pointer, so a single pass had never measured a touch target
at any viewport. It now runs every viewport twice, once with a mouse and once
with a thumb.

A check that decides it doesn't apply now says so, rather than reporting an empty
offenders list — which reads exactly like a pass.

Faded text is measured instead of filed as unchecked. `opacity` on a box fades
the box and its text together onto whatever is behind it, so a dimmed seat is not
grey text on a dark puck: it is a faded puck and faded text both landing on the
page. Both halves are composited that way now. Two exemptions remain and both say
so out loud: a disabled control, which WCAG itself excuses, and a link inside a
sentence, whose size is set by the line it sits in.

What running them found, and this round fixes:

  - the chair number on every seat: #8a8a8a at 9px, 3.39:1 once the empty seat's
    fade is composited, on the label that answers "which seat is that?";
  - Quick Split's amount field at 37px tall and its delete button at 30px across,
    on a page whose targets had never been measured;
  - and on a 360x640 phone with the bar grown by a side pot, the bottom seat
    sitting 38px inside it.

That last one brings back a cap this project measured and rejected once: capping
the felt to the room left fixed the six-seat ring and broke the nine-seat one.
That trade was real then and isn't now — seats are spaced by distance around the
rim rather than by angle, so a shorter felt no longer piles them up. Re-measured
across 30 combinations of five phone sizes and 2 to 12 seats: every one clean
except twelve seats at 360x640, where a 240px felt and a 64px puck cannot both be
true. Where the rim can't give each seat a puck's width, the pucks come down a
size, measured from the rim itself rather than from a media query.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 04:02:43 -06:00
jheaps ca9ac7b6af Add the straddle round to the release 2026-09-02 03:32:58 -06:00
jheapsandClaude Opus 5 fc866597eb Deal the straddle, instead of leaving the dealer to fake it
A straddle is a blind raise put out before the cards by the player left of the
big blind, for twice it. Home games run them constantly, and the tool had no
idea they existed — so a dealer's only way through was to deal the hand and have
the straddler "raise" to $4, which gets the money right and the other two rules
wrong.

There are three at once:

  - the amount doubles as it goes left, and re-straddles carry on doubling;
  - first to act is left of the *straddler*, not left of the big blind, and the
    straddler keeps the option when it comes back round even if nobody raised;
  - the minimum raise is twice the straddle. Measured off the big blind
    underneath it, a $2/$4 game prices the next raise at $6, and every player at
    the table will argue — correctly.

Tap Straddle on the deal prompt and the tool posts it, puts the right player on
the clock and prices the raise. The seat wears an STR badge for the hand, read
off what the deal actually posted rather than off the count, so the badge can't
outlive the chips it stands for. It's a one-hand decision, like posting in, and
it clears when the button moves.

The control sits on the deal row rather than a line of its own: measured at
390x844, its own line cost 44px of felt on every deal prompt, straddle or no
straddle, and this bar is docked over the seats. Three buttons across a 360px
phone want to wrap, which made the bar 64px taller and put the bottom seat three
fifths behind it — this layout deliberately doesn't shrink the felt to suit, so
the deal button is given a 7rem floor instead, which keeps the row to one line
and the cost to nothing.

20 new tests, checked by breaking each of the three rules on purpose first: the
wrong first-to-act, the minimum measured off the big blind, and a flat rather
than doubling chain each fail a different one.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 03:32:17 -06:00
jheaps 80dae56f0d Add the landscape round to the release 2026-09-02 03:13:05 -06:00
jheapsandClaude Opus 5 b97453fba1 Seat the table properly on a phone held sideways
Landscape is the one shape this layout has never really fitted, and the audit
was calling two separate faults there.

The felt worked its own height out as `100vh - bar - 7rem`, guessing that
everything above it came to 7rem. That is 108px at 640x360, which the guess
covers, and 119px at 844x390, which it doesn't — so on an iPhone on its side the
bottom row of seats sat up to 26px inside the action bar. The measurement it
needed was already being published for the wide layout: the bar's real height,
the wrap's real offset and the bottom seat's overhang, all taken off the window.
It uses that now. The floor under it drops from 220px to 96px, because 220 was
not a size a 390px-tall window has to give — holding out for it was itself how
seats ended up behind the Fold button, which is the one thing the floor exists
to prevent.

That still left the header taking two rows, and the second row is 44px of the
390. The title block and the five buttons came to 829px inside a 765px header,
missing one line by 64px. The page title goes — the one thing on this screen a
dealer already knows — hidden from the eye rather than removed, so the heading
is still there for a screen reader. The buttons tighten across but never down:
they stay a 44px thumb target.

The seats themselves were spaced by angle, which is only even spacing on a
circle. On a 601x126 felt, ten seats at 36° apart put four of them down each
steep end, 20px apart, with pucks 72px tall — a seat's centre belonged to its
neighbour, so tapping a player got the wrong one. They now walk the rim by
distance instead, which puts them along the two long rails the way a real table
seats people. Closest neighbours go from 33px apart to 59px.

Measured at 640x360, 667x375 and 844x390 with 9-, 10- and 12-seat tables: every
combination the audit used to fail is clean, bar a 10-seat table at 640x360,
where two seats at the end of a rim that flat still overlap. Portrait, tablet
and desktop are unchanged — the floor never bound there, and on a rounder felt
walking the rim gives back the same angles it always did.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 03:13:00 -06:00
jheaps 6ca99fde6b Bring master's already-merged rounds into the release branch
Master merged #58-#61 (manual deal, less guessing, seat editor, landscape
table) while the stack kept going. Those commits are already in this branch's
history; this merge just reconciles the two shapes so the release can go in as
one.
2026-09-02 01:19:56 -06:00
jheapsandClaude Opus 5 419852a1f4 Let a dealer take the table off the device it's stuck on
The night lives in one browser's localStorage and nowhere else. A dealer whose phone
dies, or who wants to carry on from the tablet after the break, loses the stacks, the
buy-ins, where the button is and the whole hand log. The CSV export can't bring any of
it back: it records what happened, not where the table stands.

The table can now be written to a file and read back. Round-tripped through the real
UI, a hand in progress comes back mid-hand — blinds posted, the same seat on the
clock, the button where it was.

Loading replaces everything, so it asks first and names both sides rather than saying
"are you sure": how many players are in the file and when it was saved, against how
many players and how many logged hands are about to go. It goes through the same
undoable path as the header buttons, so the toast offers the way back, and the
confirm says so instead of overstating it.

Reading a file goes through exactly the same migration a save from storage does —
load() had that logic inline and it is now a function both call. A second, looser path
for imports is how a format grows two meanings of the same field.

What it refuses, each with something a dealer can act on and never a half-loaded
table: anything that isn't JSON, JSON with no seats in it, a save from some other tool
that happens to carry a `kind`, and a file from a newer version — that last one
because loading it risks reading a field whose meaning has changed, and the dealer
would find out from the money. What it accepts anyway: a bare table with no envelope,
for somebody who copied the storage value out by hand.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 01:07:13 -06:00
jheapsandClaude Opus 5 8a2fc3e540 Give undo and the card picker's buttons a fingertip to land on
The first thing the repaired audit found, run on a real touch context at 44px: three
controls a dealer taps on a phone that are too small to tap.

    .actionUndo             145x30   the "I recorded that wrong" pill
    #cardPickerClearBtn      50x40   wipes a player's cards
    #cardPickerCloseBtn      50x40   closes the picker

Undo is the worst of them. It is the control a dealer reaches for having just entered
the wrong action with five people waiting, and it was fourteen pixels short of a
fingertip. The picker's two are short because the touch rule that fixes every other
small button is scoped to .panelBody, and the picker is a sheet — it never reached
them. One of them is destructive.

All three now sit at 44px on touch. Nothing changes on a pointer device.

I tried an invisible hit-area pad on undo first, to avoid spending bar height, and the
measurement said it did nothing: the pill clips its overflow to keep a long label on
one line, and giving it a positioning context made it clip the pad too. Growing the
box is the honest fix, and it turned out to cost nothing anyway — the bar goes from
138px to 152px and the felt stays at 366px, because the felt had slack against its
220px floor rather than sitting on it as I had assumed.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 00:55:56 -06:00
jheapsandClaude Opus 5 b64da7a787 Make the layout audit usable inside modals, and stop it passing what it can't measure
Last round turned up that Quick Split had never been audited. Chasing that, the reason
the dealer table's modals had never been audited either turns out to be the harness,
not neglect: it reported the pot wizard covering the seats as ten faults and the
rulings sheet as seven. A modal covering the page is what a modal is for, and an
instrument that calls that a fault is one nobody can use in those states.

It now finds whatever is open on top and scopes the reachability checks to it — the
overlays, plus the phone seat sheet, which is a body class rather than an element. The
seat-ownership check is skipped outright while a sheet is open: seats behind it aren't
fighting each other for taps, nothing is meant to reach them at all.

Two worse faults came out of proving the fix rather than assuming it. Injecting a 20x14
button into the open wizard, the contrast check caught it and the touch-target check
did not:

  * effectiveTarget returns null when elementFromPoint can't find the control, and the
    check treated that as a pass. Silent skips are how faults hide — the same lesson as
    the aria-hidden and faded-text exemptions before this. Unmeasurable controls are
    now reported as warnings.
  * The probe failed on anything scrolled out of its container. That was hiding a real
    control: "Confirm Winner(s)", the button that pays the pot out, had gone unmeasured
    for thirty-six rounds. It is scrolled into view before probing, and passes.

A checkbox inside a label is now measured by its label, since tapping the label is what
activates it. Without that, the dedicated-dealer toggle read as an unprobeable 24px
target when it has a 335x44 label around it.

The tool itself came out clean: no failures and no warnings across the table, the pot
wizard, the rulings sheet and the seat editor, on a real touch context at 44px. The
value here is that those states can be checked at all from now on — verified by
injection, which the fixed check catches and the old one did not.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 00:37:47 -06:00
jheaps b2262f8604 Merge pull request 'Wait to be told to deal, instead of guessing' (#58) from fix/casino-holdem-manual-deal into master
Build and Deploy / build-deploy (push) Successful in 1m10s
Reviewed-on: #58
2026-09-01 10:57:23 -06:00
jheaps 6ad9923aae Merge pull request 'Stop the last two things the table did behind the dealer's back' (#59) from fix/casino-holdem-less-guessing into fix/casino-holdem-manual-deal
Reviewed-on: #59
2026-09-01 10:57:17 -06:00
jheaps 168e8a380c Merge pull request 'Tapping a seat gives you that seat' (#60) from feature/casino-holdem-seat-editor into fix/casino-holdem-less-guessing
Reviewed-on: #60
2026-09-01 10:57:10 -06:00
jheaps edc08d7213 Merge pull request 'Let the landscape table use the width it has' (#61) from fix/casino-holdem-landscape-table into feature/casino-holdem-seat-editor
Reviewed-on: #61
2026-09-01 10:57:01 -06:00
129 changed files with 23799 additions and 973 deletions
+6 -1
View File
@@ -366,4 +366,9 @@ MigrationBackup/
FodyWeavers.xsd
# Native chess engine CMake build output (the compiled .dll/.so are committed under Resources/)
native/**/build/
native/**/build/
# Playwright MCP scratch output
.playwright-mcp/
# Private /socialism working files (founding draft, progress log) - repo is public
JoshHeaps.Net/Content/Socialism/_*.md
@@ -0,0 +1,207 @@
---
title: When a Locked Co-op Goes Bankrupt
section: The Co-op
order: 31
summary: Federal bankruptcy law would override the lock, so a failed co-op could be sold whole to anyone, and this page lays out the amendment Congress would need to pass, modeled on how it already treats charities, along with what it costs creditors and how workers get a real chance to buy.
---
# When a Locked Co-op Goes Bankrupt
[The lock](the-lock.md) says a co-op funded by the bank can never be sold to conventional owners. That holds while the co-op is healthy. It can break at the worst moment, when the co-op fails and a bankruptcy court takes charge of what's left.
This page explains why, what Congress already does for a similar kind of organization, and what the amendment would say. A few terms first:
- **The lock-holder** is the independent organization that holds the co-op's *lock share*, a single share that pays nothing but can veto a sale ([The Lock](the-lock.md#how-the-lock-is-made-to-hold)).
- **A going concern** is a business sold whole and still running, with its customers, contracts and trained staff. It can be worth much more than the same equipment sold off piece by piece.
- **Creditors** are everyone the co-op owes: lenders, suppliers, workers, tax agencies, and anyone it has injured.
## Why bankruptcy breaks the lock
When a business files for bankruptcy, everything it owns goes into a pool the court controls, called the *estate*, and is used to pay creditors as much as possible. There are three main routes:
- **Liquidation (Chapter 7).** A trustee sells what the business owns and pays creditors in order of priority. The trustee can keep the business running for a limited time if that helps sell it ([11 U.S.C. § 721](https://www.law.cornell.edu/uscode/text/11/721)).
- **Reorganization (Chapter 11).** The business keeps running under a plan the court approves. Debts are cut or stretched out, and ownership often passes to creditors. Under the *absolute priority rule*, if a group of creditors votes against the plan, the old owners can't keep anything until those creditors are paid in full ([11 U.S.C. § 1129(b)(2)(B)](https://www.law.cornell.edu/uscode/text/11/1129)).
- **Small-business reorganization (Subchapter V).** A faster version of Chapter 11 for businesses owing no more than $3,424,000 ([11 U.S.C. §§ 1182(1) and 101(51D)](https://www.law.cornell.edu/uscode/text/11/101), as adjusted in 2025). Here the absolute priority rule doesn't apply. If creditors don't agree to the plan, the owners can still keep the business by paying creditors all of its projected *disposable income*, what's left after the costs of running it, for three to five years ([11 U.S.C. § 1191](https://www.law.cornell.edu/uscode/text/11/1191)). Most locked co-ops would fit under that limit.
The lock can fail in four places:
1. **The estate ignores limits on selling.** The Bankruptcy Code says a business's property goes into the estate "notwithstanding any provision" in an agreement or in state law "that restricts or conditions transfer" of it ([11 U.S.C. § 541(c)(1)](https://www.law.cornell.edu/uscode/text/11/541)). The lock is exactly that kind of provision.
2. **The court can sell everything, "free and clear."** A trustee can sell the estate's property outside the ordinary course of business, free and clear of other people's interests in it ([11 U.S.C. § 363(b) and (f)](https://www.law.cornell.edu/uscode/text/11/363)). A court could treat the lock-holder's rights as one of those interests and pay them off in cash. In practice, a failed business usually goes to the highest bidder.
3. **A reorganization plan can hand the firm over.** A plan can sell the business, merge it or rewrite its charter "notwithstanding any otherwise applicable nonbankruptcy law" ([11 U.S.C. § 1123(a)(5)](https://www.law.cornell.edu/uscode/text/11/1123)). A standard plan cancels the old ownership and issues new shares to creditors. The Code also requires the reorganized company's charter to ban shares that carry no vote ([§ 1123(a)(6)](https://www.law.cornell.edu/uscode/text/11/1123)), while this model relies on outside money that carries no vote ([Capital, After](capital.md)).
4. **A lender can sell without going to court at all.** A lender with a claim on all of a business's assets can sell them, or take them, after a default under state law ([UCC § 9-610](https://www.law.cornell.edu/ucc/9/9-610)). No bankruptcy is needed.
So without a change in the law, a failed co-op's whole business could be bought by a conventional company. The lock would protect only healthy co-ops, which need it least.
## What Congress already does for charities
Congress already keeps some organizations' property dedicated to its purpose in bankruptcy. The way it does it is simple: the Bankruptcy Code steps back and lets the ordinary law that governs those organizations decide.
- **A charity's property can go to a non-charity "only under the same conditions as would apply if the debtor had not filed"** for bankruptcy ([11 U.S.C. § 541(f)](https://www.law.cornell.edu/uscode/text/11/541)). This covers tax-exempt charities.
- **Court-approved sales by a nonprofit corporation must follow the law** that governs its transfers outside bankruptcy ([§ 363(d)(1)](https://www.law.cornell.edu/uscode/text/11/363)), and so must transfers under its reorganization plan ([§ 1129(a)(16)](https://www.law.cornell.edu/uscode/text/11/1129)). These three rules were added in 2005.
- **Since 1978, creditors can't force a nonprofit into bankruptcy** ([§ 303(a)](https://www.law.cornell.edu/uscode/text/11/303)), and a court can't turn its reorganization into a liquidation unless it asks ([§ 1112(c)](https://www.law.cornell.edu/uscode/text/11/1112)).
- **Also since 1978, valid limits on transferring a person's interest in a trust hold in bankruptcy** ([§ 541(c)(2)](https://www.law.cornell.edu/uscode/text/11/541)). That rule keeps some assets away from creditors, so it's a precedent for protecting a purpose at creditors' expense, not a neutral one.
A locked co-op isn't a charity. It's a business that makes and shares profits. The comparison is narrower: in both cases, assets are meant to stay dedicated to a purpose, not sold for private gain. So the amendment copies the *technique*: the Code defers to the law that governs locked co-ops, and that law, the bank's statute, says what can be sold to whom.
**Is it constitutional?** Bankruptcy laws must be "uniform." In 1982 the Supreme Court struck down a law written for the workers of one bankrupt railroad, calling it "nothing more than a private bill." But it said a bankruptcy law must "apply uniformly to a defined class of debtors," and that its ruling didn't impair Congress's ability "to define classes of debtors and to structure relief accordingly" ([*Railway Labor Executives' Ass'n v. Gibbons*, 455 U.S. 457](https://www.law.cornell.edu/supremecourt/text/455/457)). A rule for every locked co-op, defined by what it is and not by name, is that kind of class. The parts that reach sales outside bankruptcy (point 9 below) rest on Congress's power over interstate commerce instead.
## What the amendment does
### Who it covers
**1. Every locked co-op on the federal register.** The bank's statute sets up a public register of locked co-ops. A co-op is covered while its lock is on the register. The register decides the question. A notice in the state filing system lenders already search (a UCC financing statement) points to it, but if that notice lapses, the co-op stays locked.
**2. Old debts are protected.** A lender already secured on the co-op's assets when the lock is registered has to consent in writing before registration. For a business that converts to a co-op, that's part of the buyout deal ([When an Owner Wants to Sell to the Workers](buyouts.md)).
**3. Creditors who never chose to lend are made whole for the lock's cost.** Some creditors never had a chance to price the lock in: people the co-op injured, tax agencies, and creditors whose unsecured claims came before registration (exactly who counts is below). If the lock makes them recover less than they would have from an open sale, the federation fund pays them the difference. For co-ops outside a federation, the bank pays it. Locked co-ops must also carry standard liability insurance, so injured people are mostly paid by insurers anyway.
**How big that bill could be.** Counted in cases, usually nothing. Counted in dollars, the cost sits in a few large cases, and nobody has measured how often they happen.
- **Most failed small businesses have little to fight over.** In one study of small firms liquidated under Chapter 7, creditors got back a median of 5.8% of what they were owed, and an average of 27% (Bris, Welch and Zhu, 2006, as summarized in [Corbae and D'Erasmo, NBER w23515](https://www.nber.org/papers/w23515)). Most small businesses that try to reorganize are liquidated in the end ([Morrison, 2007](https://ideas.repec.org/a/ucp/jlawec/v50y2007p381-419.html)). That's background, not an estimate. Those are firms nobody would buy whole, where the lock changes nothing. The fund pays in the other case: a business still worth more than it owes the bank, brought down by a debt nobody chose, like a large injury claim. That's also where the payment is biggest.
- **The bank stands in front.** A failed co-op usually owes the bank more than the business is worth, and any extra an open sale would have brought goes to the bank first. The bank lent knowing about the lock. So the fund only pays when an open sale would have covered the bank and everyone else ranked ahead, with money left over.
- **Who's covered.** People the co-op injured; state and local tax agencies; claims listed when the lock was registered, less whatever has since been paid; customers' deposits; and workers who don't join a new co-op, for what they're owed beyond the part bankruptcy law puts first, including damages for late wages and missing layoff notice. Federal tax claims aren't covered, because the federal government chose the lock when it passed the law. Nor are insiders, the parties to a buyout, or anyone tied to them. A court judgment counts only if the fund was told about the case in time and could defend it. Where someone is personally liable for a tax the co-op didn't pay over, like taxes withheld from wages, the fund takes over the claim against them.
- **The comparison changes only the price.** Both sides of the comparison use the same list of claims, in the same order, with the same costs of the case, and the insurance pool keeps its place in both. Workers' handed-over claims count as paid in full. The open-market price is taken after the costs a normal sale would have had. Anything a buyer of the pieces pays back under point 8 is subtracted.
- **An example.** A co-op owes the bank $100,000, its workers $30,000 of wages the pool paid, and $60,000 in state taxes, and it lost a $400,000 injury lawsuit beyond its insurance. The case costs $40,000. Sold whole on the open market, the business would bring $500,000: after costs, the bank, the wages and the taxes, the injured person would get $270,000. The workers' co-op pays $200,000, so the taxes get $30,000 and the injured person nothing. The fund owes $300,000. A co-op that owed the bank more than the business was worth would owe nothing.
- **The open-market figure has to be earned.** Nobody can test it with a real sale, and everyone involved has a reason to bend it: the buyers want the price low, the covered creditors want the gap high, and the payer wants it small. So the court picks the appraiser, and neither the fund nor the bank takes part in choosing. The payer and the covered creditors can each ask for a second appraisal, paid by the fund. The court also invites conventional buyers to make offers. They can't win, but an offer backed by a deposit sets the open-market figure. Otherwise the court decides between the appraisals.
- **The buyer pays it back.** The gap is the discount the new co-op got. So the new co-op owes the fund what it paid out, repaid from profit within ten years, behind its bank loan and its debt to the insurance pool. The fund guarantees the creditors are paid now. Only what the new co-op can't repay is a cost the whole sector bears.
- **Insurance, checked every year.** The payer, not the co-op, sets how much liability insurance each kind of co-op must carry. It covers claims from the years it was in force even after the co-op fails, and includes pollution cover where the work calls for it. A lapse counts as a default on the co-op's loan and triggers the early warning in [Paying for the workers' bid](#paying-for-the-workers-bid). Co-ops outside a federation pay the bank a premium based on their risk. In a buyout, the selling owner sets aside money for, or guarantees, the debts from before the sale that the co-op takes on.
- **During the pilot, the bank pays,** because there's no federation fund yet. Those payments are reported case by case, with each failure's appraised gap, and count as the bank's losses in the losses that can stop its lending ([The Pilot, in Numbers](pilot-spec.md#the-decision-rule)).
### What can be sold, and to whom
**4. The Code defers to the lock.** In every chapter, and in plan sales too, the lock's limits hold. The estate takes the co-op's property subject to them. A sale "free and clear" can't strip them. The lock agreements can't be rejected as ordinary contracts. And no plan can override them. The detailed sale rules live in the bank's statute, as the rules for charities live in state law.
**5. Workers can keep the business running while it's sold.** Argentina changed its bankruptcy law in 2011 to do this. The trustee must report within 20 days on whether the business can keep operating. Workers making up two-thirds of the staff can ask to run it as a co-op while the case goes on, with a plan due within 20 days. The judge decides, and sets a time limit that can be extended once ([Law 26.684, amending articles 190 and 191 of the bankruptcy law](https://www.argentina.gob.ar/normativa/nacional/ley-26684-183856/texto)). The amendment adapts this: workers can ask to run the business as a new co-op, under court supervision, while it's up for sale.
**6. The whole business goes only to a qualified co-op.**
- **A qualified bidder** is a locked co-op whose members do at least three-quarters of its work hours, and whose outside money meets the limits in [Capital, After](capital.md): no vote, at most 15 years. A co-op formed by the failed co-op's own workers qualifies from the start. So does the federation. A shell set up by a conventional company doesn't.
- **The minimum price is what the assets would bring sold off piece by piece,** set by an appraiser the court appoints. A higher floor, like the full value of a running business, could stop any co-op from buying and force a breakup, which is worse for workers and creditors both. The co-op bidders compete in open bidding, so the price is usually higher than the floor.
- **No buying back your own failure cheaply.** People who managed the co-op in the two years before it failed can't get bank financing to buy it ([The Lock](the-lock.md#when-a-co-op-goes-bankrupt)). In a reorganization where the members are running the case and also bidding, an independent officer runs the sale.
- **The time limit is 90 days from when the sale is announced, or from when the workers' co-op files its plan, whichever is later.** The court can extend it once if a qualified bidder is lining up financing.
**7. Workers can pay with what they're owed.** A secured lender can already bid using its claim instead of cash ([§ 363(k)](https://www.law.cornell.edu/uscode/text/11/363)). Argentina lets workers do the same: a workers' co-op can count the wage and severance claims its workers voluntarily hand over toward the price, at their full amount, and the handover happens at a hearing before the judge (Law 26.684, article 27, adding article 203 bis). The amendment does this too, within limits:
- **Only claims workers choose to hand over,** in writing. Workers who don't join the new co-op keep their claims.
- **At full value, up to the amounts bankruptcy law already puts first:** unpaid wages and benefit contributions earned in the last 180 days, up to $17,150 per worker for wages ([11 U.S.C. § 507(a)(4) and (a)(5)](https://www.law.cornell.edu/uscode/text/11/507)). That's a deliberate transfer from other creditors to workers, capped per person, as Argentina chose.
- **The legal minimum is paid in cash, not handed over.** Federal law requires minimum wage and overtime to be paid in cash or a cheque ([29 CFR 531.27](https://www.law.cornell.edu/cfr/text/29/531.27)), "finally and unconditionally" ([29 CFR 531.35](https://www.law.cornell.edu/cfr/text/29/531.35)), and workers can't waive them by private agreement ([*Brooklyn Savings Bank v. O'Neil*, 1945](https://www.law.cornell.edu/supremecourt/text/324/697)). A stake in a new company isn't cash. Wage claims are bought and sold in bankruptcy, and handing one over at its full value isn't a discount, so this might be allowed. But the usual way a court blesses giving up wage claims is by approving a settlement of a real dispute over them ([*Lynn's Food Stores v. United States*, 11th Cir. 1982](https://law.justia.com/cases/federal/appellate-courts/F2/679/1350/13398/)), and there isn't one here. So the model doesn't rely on it.
- **The protected part** is every unpaid hour at the highest minimum wage that applies, federal, state or local, plus all unpaid overtime at the worker's own overtime rate, taken from payroll records. For salaried staff whose hours weren't recorded, it's counted at 40 hours a week. Any damages the law adds for late wages stay with the worker.
- **Some states protect more.** California bars employers from requiring a release of any wages due until they're paid, not just the minimum ([Cal. Labor Code § 206.5](https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=LAB&sectionNum=206.5)), and no private agreement can get around its wage rules ([§ 219](https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=LAB&sectionNum=219)). The bank's checked entry for each state ([Owners Are Still Workers](labor-law.md#is-the-profit-share-pay)) says which wages can be handed over there. Where it's unclear, only severance and benefit contributions can be.
- **The pool pays first.** The [savings insurance pool](savings-guarantee.md) pays up to eight weeks of each worker's unpaid wages in full, as it always does, and what it pays counts against the protected part first. Any protected wages beyond that stay the worker's own claim, and the court can't approve a bid that uses handed-over claims until the buyer has set aside cash to pay them.
- **Workers can hand over the rest:** wages beyond what the pool paid that the protected part doesn't cover, benefit contributions and severance, within the cap above.
- **Handed-over claims buy savings, not votes.** In the new co-op, they become credits in each worker's [capital account](capital-accounts.md). Votes stay one per member, so a manager with a larger claim doesn't get more say than an aide whose wages were paid by the pool.
- **The insurance pool's claims count too.** Normally, whoever pays a wage claim loses its priority ([§ 507(d)](https://www.law.cornell.edu/uscode/text/11/507)). Without a fix, most low-paid workers' claims would be paid by the pool and couldn't be used to bid. So the pool keeps the priority, and when a qualified workers' co-op bids, it hands the claims it paid to that co-op for its bid. The new co-op repays the pool over time. Each worker's own claims use the $17,150 priority first, and the pool's claim for that worker only ranks after them.
- **No last-minute claims.** In the 180 days before filing, pay deferred by a vote, pay left unpaid for more than one normal pay period while others were being paid, and any increase in notice or severance terms rank as ordinary claims, not priority ones, and can't be used to bid.
**8. If no qualified co-op buys it, the pieces go to anyone, but not the whole.** No buyer, together with anyone financially tied to it, may end up with substantially all of the business. The brand, the core technology or the main customer contracts count as substantially all, whatever their size, as the lock already says. A buyer of the pieces who hires more than a third of the members within two years owes the difference between what it paid and what the running business was worth, as set by the court's appraiser. The sale order says so up front. That money goes to the co-op's creditors, not the federation fund.
**9. Lenders can't sell around it outside court either.** The same rules apply to any transfer of substantially all of a locked co-op's assets because it can't pay its debts: a lender's sale or taking of its collateral, a state receivership, an assignment for the benefit of creditors, or a tax sale. The [model state law](lock-state-law.md#what-the-model-law-does) writes these rules into state law too, and adds that a sale following these rules counts as "commercially reasonable" under the UCC, so lenders aren't penalized for following them.
### Paying for the workers' bid
A workers' co-op can only use these rules if someone lends it the money in time, on terms a failed business can meet. The best-documented US case is New Era Windows in Chicago. Its workers had occupied their factory in 2008. When a later owner closed it in 2012, a group of them formed a co-op. They didn't buy a running business. The plant was shut, and they bought the equipment they wanted at the creditors' auction for $430,000, at salvage prices. They needed $850,000 in all, counting money to restart and run it, and put in about $20,000 themselves, $1,000 each. One mission lender, The Working World, provided the rest, in ten-year loans at 5% repaid only once the co-op made a profit ([Project Equity](https://project-equity.org/ownership-story/new-era-windows-deal-structure/)). The co-op opened in May 2013 ([The Working World](https://www.theworkingworld.org/us/new-era-windows/)). Money repaid only from profit is closer to ownership than to a loan, and a bank lending on past cash flow would probably have turned New Era down. So the case shows what kind of money a restart needs, and the rules below don't pretend the bank's ordinary test can supply all of it.
- **Warning early, and privately.** Members and the lock-holder are told what these rules let them do at the first of: 60 days behind on a loan from the bank, a missed payroll, unpaid payroll taxes, deferred insurance pool premiums, loan terms eased to avoid a default, lapsed liability insurance, or a layoff notice. Members own the business, so they're told privately, through an adviser, not in a letter that reaches suppliers. Every worker, including temporary and agency staff, is told once a loan is called in or a case is filed, in the languages they speak.
- **An adviser who works for the workers.** The workers pick one from a panel the [federation](federation.md) keeps. The adviser is paid from a small charge on all the bank's loans, not by the co-op's managers or by the bank case by case.
- **Keeping it running.** A court can already approve new lending to a business in bankruptcy that ranks ahead of the case's other costs ([11 U.S.C. § 364(c)](https://www.law.cornell.edu/uscode/text/11/364)). It can't rank ahead of an existing lender's lien without that lender's protection being shown ([§ 364(d)](https://www.law.cornell.edu/uscode/text/11/364)). When workers run the business while it's up for sale (point 5), the bank may make such loans for day-to-day costs, on limits the statute sets: they can't rank ahead of wages earned while the workers run it, can't be used to pay off the bank's old loans or to pledge assets for them, and can't carry a deadline that forces a sale before the 90 days in point 6. If the workers' co-op wins, the loan can be folded into its purchase loan.
- **A separate test for buying a failed co-op.** The bank's usual buyout test looks back at two or three years of checked cash flow ([When an Owner Wants to Sell to the Workers](buyouts.md#the-seller-shares-the-risk)), which a failed firm doesn't have. So the bank lends against what the assets would bring sold off piece by piece, as set by the court's appraiser, and no more. The rest of the price and the money to run it have to come from the workers' handed-over claims, the insurance pool's claims, another lender, or the federation's fund, which can lend on terms like New Era's, repaid only from profit, within the limits it sets in advance ([Investing for the Long Run](the-long-run.md#spending-it-badly)). That also stops the bank from raising the price, and its own recovery, with its own money.
- **The same offer to every qualified bidder,** in writing. If the bank turns a bid down, it gives its reasons to the workers and the court. When two qualified bids are equal, the one whose members include more of the failed co-op's workers wins. People who managed the failed co-op in its last two years can join as members, but the bank won't lend to a bid they lead.
- **Counted in the bank's record.** Nobody who worked on the failed co-op's loans decides on the new one. The failed co-op counts as closed ([Keeping zombies alive](pilot-safeguards.md#keeping-zombies-alive)). The failed loan's recovery counts only money that didn't come from the bank, and the new loan is reported together with it, as one lending decision. Any loss on the new loan in its first five years is added to the failed loan's loss, in the published recovery rate and in the losses that can stop the bank's lending ([The Pilot, in Numbers](pilot-spec.md#the-decision-rule)). So lending the workers the money to buy a failed co-op can't hide a loss.
- **Repaying the insurance pool.** The pool chooses whether to hand over the wage claims it paid (point 7), under a test it publishes in advance. If it does, the new co-op owes it that amount at the one-year Treasury rate plus two points, the rate members' accounts earn, secured by a lien behind the bank's. That's below what a lender would charge a restarted business, a subsidy from the other co-ops' premiums, and it's stated as one. The co-op repays within ten years, which the pool can extend once. Each year's payment comes before any pay above the going rate and before any profit share paid in cash beyond what members need for tax.
### When the co-op reorganizes
**10. A reorganized co-op stays a co-op, and creditors are treated as they would be anyway.**
- **Only working members hold votes.** A plan can give creditors new debt, or voteless claims on the terms in [Capital, After](capital.md), but never ownership, and no loan terms giving a lender approval over the co-op's management or strategy. The rule requiring a ban on voteless shares doesn't apply.
- **Small co-ops follow the ordinary small-business rule.** In Subchapter V, owners of any small business can keep it by paying creditors all of its disposable income for three to five years. A co-op gets the same deal, no better. To stop members shrinking that income by paying themselves more, disposable income is worked out with members' pay at the going rate ([Investing for the Long Run](the-long-run.md#the-rules)), and raises in the two years before filing are reversed.
- **Larger co-ops pay creditors the business's full value.** If a group of creditors votes against the plan, they must get at least what the business would sell for on the open market, as if the lock didn't exist, in cash, new debt or voteless claims. That's what the absolute priority rule protects in any company. Members can keep a co-op they can't sell, but not at their creditors' expense.
- **That value is tested, not just appraised.** The Supreme Court held in 1999 that owners can't keep a business over a creditor group's objection through a deal nobody else could compete for, because "the best way to determine value is exposure to a market" ([*Bank of America v. 203 North LaSalle Street Partnership*](https://www.law.cornell.edu/supremecourt/text/526/434)). It left open what kind of market test is enough. Here the test is weaker than a real one, because a conventional buyer can never win. So when members propose to keep a co-op over a creditor group's objection, the court picks the appraiser, as in point 3, and invites offers for the business. Offers have to be in binding form, with a deposit the offeror loses if the offer turns out to be unfinanced or is withdrawn, and creditors and anyone tied to them can't make them. The fund pays reasonable costs of checking out the business to offerors who make one. The highest such offer sets the floor. If none comes in, the floor is the higher of the two appraisals. Any creditor can also propose a competing plan, including a sale to another qualified co-op, and the members lose their exclusive right to propose a plan. Whether that satisfies the courts is untested. Small co-ops under Subchapter V aren't affected: the absolute priority rule doesn't apply there, and only the debtor can propose a plan ([§ 1181(a)](https://www.law.cornell.edu/uscode/text/11/1181)). That's also a way around the test, so a co-op facing injury claims above its yearly sales can't use Subchapter V.
- **Members' own accounts go first.** What members have in their capital accounts is wiped out before creditors lose anything. Members were taxed each year on those allocations, so this is a real loss, and it's the cost of being an owner ([Member Capital Accounts](capital-accounts.md#protecting-the-co-op-and-the-people-who-stay)).
### Everything else
**11. Former members are paid before current members.** A former member's payout is a fixed debt the co-op owes ([Member Capital Accounts](capital-accounts.md#how-its-paid-out)). It ranks behind every other unsecured creditor, but ahead of current members' accounts. It isn't pushed down to the bottom as if it were a shareholder's claim ([§ 510(b)](https://www.law.cornell.edu/uscode/text/11/510)). Anyone who left within a year before the failure has their balance recalculated first, as the insurance pool already does ([Protecting Members' Savings](savings-guarantee.md#closing-the-loopholes)).
**12. The lock-holder can go to court, carefully.** The lock-holder can take part in the case. If the trustee declines to challenge a deal the co-op made with insiders before it failed, the lock-holder can ask the court's permission to challenge it, and pays the other side's costs if the court finds the challenge frivolous.
**13. Leased equipment, licenses and sold invoices.** A co-op doesn't own what it leases or licenses, or the unpaid invoices it has sold to a lender (called factoring), so the lock can't hold them directly. A business that rents its shop, leases its machines and licenses its software could be taken apart by the people it rents from. So the lock sets terms for the things a co-op can't run without.
- **In bankruptcy, many leases can go with the business.** The trustee can keep a lease and hand it to the buyer, even if the lease forbids transfers, as long as rent that's behind and the lessor's other money losses are made good and the buyer shows it can pay ([§ 365(b) and (f)](https://www.law.cornell.edu/uscode/text/11/365)). There are big exceptions. A commercial lease the landlord ended before the filing can't be revived. Where other law lets the other side refuse to deal with anyone new, as it usually does for patent, software, trademark and franchise licenses, the license can't be handed on without consent, and in several circuits the co-op can't even keep it (§ 365(c)(1)). Credit lines, including a factoring line, end at filing (§ 365(c)(2)).
- **Deadlines are held open, and the user pays the rent.** In a liquidation, leases of equipment and other ongoing contracts count as ended if they aren't kept within 60 days (§ 365(d)(1)). A commercial property lease ends after 120 days, extendable once by 90, and after that only with the landlord's written consent (§ 365(d)(4)). The co-op sale can outlast those deadlines. So the amendment holds them open until the co-op sale ends, which has its own time limit (point 6). The rent in the meantime is paid by the workers' co-op running the business under point 5 if there is one, and otherwise by the federation fund, or the bank in the pilot, not by the other creditors. The lessor can still ask the court to protect its property from damage.
- **Essential leases and licenses carry the lock's terms from the start.** The things a co-op can't run without are its premises, its main production equipment, the software that holds its customer and operating records, and its brand or franchise. Any lease or license of one of those has to:
- consent in advance to being handed on to a locked co-op that takes over the business, including the workers' own new co-op under point 5, on the same terms and with no transfer fee or rent increase. The lessor must accept that co-op if the federation fund, or the bank in the pilot, guarantees its first 12 months of payments. Advance written consent is what the bankruptcy exception for licenses requires;
- not let the lessor end it just because the co-op is insolvent or in bankruptcy, only for real default after notice and a chance to fix it;
- for premises, give a co-op successor the same renewal rights the co-op had; for software, let the co-op export its own data in a usable form.
The lock-holder checks these clauses when the lease is signed. Where the only supplier of something is a large firm that won't negotiate, like a mass-market software service, the lock-holder can waive the clauses if the co-op can move its data and switch to a comparable supplier. Some lessors will charge more for these terms, or refuse to deal.
- **A sale-leaseback is a sale.** Selling something the co-op owns and leasing it back counts toward the lock's limit on selling a quarter of the business in three years ([The Lock](the-lock.md#closing-the-back-doors)), and it's borrowing for [the second-opinion rule](the-long-run.md#spending-it-badly). Selling and leasing back the premises or main equipment needs the lock-holder's consent, whatever its size, and the lease has to carry the terms above.
- **Nobody can reassemble the business through the back door.** Point 8 already charges a buyer of the pieces who hires more than a third of the members within two years. The same goes for anyone who, within two years, gets the use of property the co-op leased, licensed or financed, by buying, leasing or licensing it, and runs the same business with more than a third of the co-op's former workforce, members or not, or takes over its main customer contracts. A lessor that re-leases to a new company is caught through that company. What's owed is the value of the running business, less the fair value of the property the payer already owned or paid for. In bankruptcy it goes to the creditors, as in point 8. Outside bankruptcy it goes to the federation fund, as the lock's matching rule says.
- **A "lease" that's really a loan is the co-op's property.** If the co-op can't end the lease early and has to pay for the equipment's whole useful life, or can own it at the end for a token price, the law treats the deal as a loan secured on the equipment ([UCC § 1-203](https://www.law.cornell.edu/ucc/1/1-203)). Then the equipment is the co-op's, still subject to the lender's claim, and a lender's sale of it follows point 9. Lessors can easily write leases that stay on the right side of that test, so this catches only careless ones.
- **Selling invoices is limited, not banned.** Selling invoices one at a time is a common way for small firms to get paid sooner, and it doesn't move the customers, workers or equipment. Some deals go further. A merchant cash advance buys a share of future sales, takes daily withdrawals from the bank account, and often comes with a blanket claim on everything the firm owns and a signed confession of judgment. So, as part of the lock, for as long as it lasts:
- buying a share of future sales counts as outside money, on the terms in [Capital, After](capital.md), or as a loan;
- a buyer of invoices can have a claim only on the invoices it bought, never on everything the co-op owns;
- no confessions of judgment;
- no clause barring the co-op's customers from dealing with a successor co-op.
Selling invoices counts as borrowing for the second-opinion rule, and it's reported to members each year alongside new leases. While the co-op owes the bank, it needs the bank's consent, which is given automatically while the invoices sold and not yet collected are under a tenth of a year's sales.
- **It's measured.** The pilot publishes how much of its co-ops' premises, equipment and software is leased or licensed, what the rent costs as a share of what the co-op produces, and how concentrated the lessors are, so if this becomes a way around the lock, it shows.
**14. Customers and people hurt later.** A buyer of a bankrupt business usually takes it "free and clear" of the old company's debts. That's the ordinary rule, not the lock's. But a workers' co-op that buys a failed co-op is often the same people, making the same things under the same name. Outside bankruptcy, state law can make a buyer answer for the seller's debts when it's a "mere continuation" of the seller, and a few states, like California, make a buyer that carries on the same product line answer for injuries from the seller's products ([*Ray v. Alad Corp.*, 1977](https://static.case.law/cal-3d/19/cases/0022-01.json)). How far a bankruptcy sale cuts those claims off is unsettled for people who are hurt only after the sale. When GM was sold in 2009, the appeals court later held that the sale order couldn't bind people who bought used Old GM cars after the sale, since they'd had no dealings with Old GM ([*In re Motors Liquidation Co.*, 2d Cir. 2016](https://cases.justia.com/federal/appellate-courts/ca2/15-2844/15-2844-2016-07-13.pdf)). Points 3 and 6 already stop members from using a failure to shed claims people have now: the fund pays the covered creditors what the lock cost them, and the new co-op pays the fund back. This point covers the people who aren't in the case yet.
- **Whoever carries on the business takes on a minimum.** GM's buyer agreed to answer for accidents after the sale, to make repairs under the old company's written warranties, and to answer for lemon-law claims. That was a negotiated deal, under political pressure, and most buyers in bankruptcy sales don't take on injury claims. The lock requires it. These obligations bind a co-op that buys the whole business under point 6, a co-op whose members keep it through a plan under point 10 or Subchapter V, and anyone caught by the back-door rules in points 8 and 13, in court or out of it:
- injuries after the sale caused by products the old co-op made or work it did. In a plan, these aren't wiped out by the discharge;
- repairs or replacements under the old co-op's written warranties. Money claims for breaches before the sale stay in the case;
- household customers' deposits for things not yet delivered: the business delivers, or refunds the deposit, up to the amount bankruptcy law already puts ahead of other unsecured claims per person ([§ 507(a)(7)](https://www.law.cornell.edu/uscode/text/11/507)). A delivery or refund cancels that customer's claim in the case and any payment for it under point 3, so nobody is paid twice. Business customers' deposits stay in the case.
These are a minimum. The amendment doesn't cut off any claim against a buyer that state law would allow.
- **Who pays for it.** In a sale, these come on top of the cash price, which still has to meet the floor in point 6, so the bidders price them in. In point 3's comparison, the open-market sale carries only what the appraiser finds buyers usually take on in such sales, like warranty repairs and deposits, not future injuries. So protecting people hurt later doesn't shrink what the fund pays people hurt already. The bank's bid lending counts the estimated cost too.
- **Insurance carries most of it.** Injuries from the old co-op's products and completed work have to be insured, on the terms the payer sets for all co-ops (point 3). The estate can buy that cover, called tail or discontinued-products insurance, before creditors are paid, or the buyer can. Where nobody sells it at a sane price, the buyer sets aside a reserve from its profit instead, sized by the representative below and backed by the federation fund.
- **The limits.** Beyond insurance, what the lock requires lasts ten years after the sale or the state's own limit on product suits, whichever is longer. It's capped, in total for all such claims, at the business's open-market value found under point 3. There's no cap or time limit where insiders hid a defect they knew about, or where a known claimant didn't get notice. If the new co-op is later sold, the next buyer takes on what's left.
- **Someone speaks for people who aren't hurt yet.** Where the old co-op built, made or handled something that could injure people years later, like buildings, machinery, food or chemicals, the court appoints a representative for future claimants. The idea is borrowed from asbestos cases ([§ 524(g)(4)(B)(i)](https://www.law.cornell.edu/uscode/text/11/524)), where the representative's job is bargaining over a trust. Here it's narrower: the representative can object to the sale, the plan, the insurance, the reserve and the value. Known claimants get notice by mail, not just by publication. The GM ruling held that people the old company knew or should have known about, and who didn't get real notice, weren't bound by the sale.
- **Outside bankruptcy, state law still applies.** When a lender sells a failed co-op under point 9, there's no court order cutting off old debts, and state successor-liability law applies in full, on top of the minimum above. It's one more reason a workers' bid will usually go through the court.
**What it doesn't do.** Unlike charities, locked co-ops *can* be forced into bankruptcy by their creditors, and a failed reorganization *can* be turned into a liquidation. A business that can't pay its debts shouldn't be able to shelter behind the lock. The amendment only decides who can buy it, not whether it can fail.
## What it costs, and who pays
- **Lenders get back less when a co-op fails.** The whole business can only go to a co-op, so it may sell for less than on the open market. Lenders who lend after the lock is registered know that, and they'll charge for it, in higher rates or more collateral.
- **The bank bears it first.** The bank is usually the biggest lender, so the pilot publishes its recovery rate on failed loans ([The Pilot, in Numbers](pilot-spec.md#what-gets-measured)). That shows what the lock costs.
- **The federation fund bears the rest.** Creditors who never chose to lend are paid the lock's cost from the fund. Most failures cost it nothing, but a single case with a large uninsured claim could cost it hundreds of thousands of dollars or more, until the co-op that bought the business pays it back. That's a real call on money the co-ops put in.
- **Other lenders may lend less, or not at all.** Some banks will refuse locked co-ops, or lend only against equipment they can take back. Lenders willing to fund a failing co-op through a rescue may be harder to find, because they can't sell the business to anyone. That's a real cost, and it's part of why the model needs its own bank.
- **Workers' claims come ahead of other creditors when workers bid.** That's a choice to favor keeping a business running in its workers' hands. It's capped per worker, and it's paid for by other creditors.
- **Low-paid workers bring less of their own to the bid.** Their unpaid wages are mostly the legal minimum, which is paid in cash rather than handed over, so more of the bid rests on the pool's claims and on better-paid workers' claims. Whether the rule is needed at all depends on a legal question nobody has tested: whether a worker can freely hand over a wage claim at full value.
- **Some viable businesses will still be broken up.** When no co-op can buy a failed co-op whole, a conventional buyer who would have kept it running and kept the jobs can't. While the co-op sector is small, this will happen. Whether to allow a conventional buyer as a last resort, with any surplus going to the federation fund, is an open question ([Open Problems](open-problems.md)).
## Draft text
For readers who want to see what this could look like in the statute, here is a first draft of the core Bankruptcy Code changes. The detailed sale rules (points 5 to 9) go in the bank's own statute. This is a starting point for lawyers, not finished legislation.
> **Sec. 101 (new definition).** The term "locked cooperative" means an entity entered, as of the date of the filing of the petition, on the register of locked cooperatives kept under [the bank's authorizing act].
>
> **Sec. 541(g).** Notwithstanding subsection (c)(1) and any other provision of this title, property held by a debtor that is a locked cooperative may be transferred only as permitted by the nonbankruptcy law governing locked cooperatives, as if the debtor had not filed a case under this title.
>
> **Sec. 363(q).** In a case in which the debtor is a locked cooperative, (1) the trustee may use, sell, or lease property under subsection (b) or (c), including under subsection (f), only in accordance with the nonbankruptcy law governing locked cooperatives; (2) the rights of a lock-holder under that law are not an interest that may be sold free and clear under subsection (f); and (3) section 365 does not permit rejection of any agreement required by that law.
>
> **Sec. 365(p) (new).** In a case in which the debtor is a locked cooperative, (1) no period under subsection (d)(1) or (d)(4) shall end before the close of any sale of the debtor's business under the nonbankruptcy law governing locked cooperatives, and the obligations of the debtor under such contract or lease arising during the extended period shall be paid as provided in that law and shall not be an administrative expense of the estate; and (2) consent given in a contract or lease to assignment to a locked cooperative that acquires the debtor's business is consent for purposes of subsection (c)(1)(B).
>
> **Sec. 507(d) (amended).** Insert at the end: "except that an insurance pool established under [the bank's authorizing act] that pays a claim of a kind specified in subsection (a)(4) or (a)(5) is subrogated to the holder's right to priority, but such priority is subordinate to any claim of the same individual under subsection (a)(4) or (a)(5) that the pool has not paid."
>
> **Sec. 1123(e).** Notwithstanding subsection (a), a plan for a debtor that is a locked cooperative (1) may not provide for any transfer of property, merger, consolidation, or amendment of the debtor's charter except as permitted by the nonbankruptcy law governing locked cooperatives; and (2) need not comply with subsection (a)(6) as to any nonvoting interest permitted by that law.
>
> **Sec. 1129(a)(17).** If the debtor is a locked cooperative, (A) all transfers of property under the plan are made in accordance with the nonbankruptcy law governing locked cooperatives; and (B) no equity security carrying voting power is issued to any entity other than members who work for the reorganized debtor.
>
> **Sec. 1129(b)(2)(B)(iii).** In a case in which the debtor is a locked cooperative, membership in the reorganized debtor held by persons who work for it is not property received or retained on account of a junior interest, if each holder of a claim of the class receives or retains under the plan property of a value, as of the effective date of the plan, not less than the amount such holder would receive if all of the debtor's property were sold as a going concern without regard to section 541(g), as determined under the nonbankruptcy law governing locked cooperatives after the court has invited binding offers for the debtor's business and has permitted any party in interest to file a competing plan, and not less than the highest such offer.
>
> **Sec. 1121(f) (new).** In a case in which the debtor is a locked cooperative, if the debtor files a plan under which members retain membership over the objection of an impaired class, any party in interest may file a plan.
>
> **Sec. 1182(1) (amended).** Insert at the end: "A locked cooperative is not a debtor under this subchapter if unliquidated claims for personal injury or wrongful death against it exceed its gross receipts for the preceding year."
## What this doesn't solve
- **It needs Congress, and a later Congress can undo it.** The lock's other layers hold if the bank's law is repealed. This one doesn't ([Making It Last](durability.md)).
- **Courts will test it.** Treating membership as something other than property the owners keep is new in bankruptcy law. The draft ties it to paying dissenting creditors full open-market value, which answers the fairness point, but the first cases will still be fought.
- **Reaching lenders' sales outside court is a federal reach into state law.** It rests on Congress's power over commerce, not bankruptcy, and it will be challenged. States that adopt the [model law](lock-state-law.md#what-the-model-law-does) make it much safer.
- **Renting leaves the co-op exposed.** Essential leases and licenses have to allow a co-op successor (point 13), but a landlord can still refuse to renew when a lease runs out, and raise the rent to whatever the market will bear. A co-op that rents most of what it uses is paying a lot of its surplus to owners outside it, and nothing in the lock stops that. Only buying its premises and equipment does. The terms above will also make some lessors charge co-ops more, or refuse them, and mass-market software can't be made to follow them at all.
- **The money only comes if the numbers work.** Many businesses fail because they can't pay their way, and a lower price doesn't always fix that. The bank lends only up to what the assets would bring sold off, so the rest has to come from patient money. In the pilot's early years there's no federation fund to provide it, and where nobody does, the business is sold in pieces. And the bank, usually the biggest lender to the failed co-op, also decides whether to lend to its workers' bid. The rules above keep those decisions apart. They can't make the bank neutral about its own losses.
- **The register has to exist and be trusted.** It's a new federal job, and every lender to a co-op will have to check it.
@@ -0,0 +1,119 @@
---
title: Help Before the First Loan
section: Growing the Sector
order: 67
summary: The pilot's hardest job is getting enough good co-ops started, so it tests, by lottery, whether serious help before launch creates more of them that last, and asks founders for a small stake they can pay in work instead of cash.
---
# Help Before the First Loan
The pilot aims to fund about 400 new co-ops in five years, about 80 a year. The whole American worker co-op sector grew by about 50 a year over the last decade, after counting the co-ops that closed ([The Pilot, in Numbers](pilot-spec.md#why-about-400-co-ops)). If good groups don't show up, the bank has nobody to lend to, and the whole test stalls.
The pilot already tests help *after* the first loan, by lottery ([the support lottery](pilot-spec.md#the-support-lottery)). This page is about help *before* it: whether serious support for groups who want to start a co-op makes more of them succeed in starting one that lasts, and whether founders should put in something of their own.
## What we know
**Mondragon's help started before a co-op existed.** When a group came with an idea, its bank picked one of them to be the manager, paid that person a salary while a team studied whether the business could work, and later lent the new co-op enough to cover its losses for its first two years ([Has This Been Tried?](precedents.md#mondragons-bank-caja-laboral)). Nobody knows how much of Mondragon's success came from that help and how much from the loans.
**Light training doesn't last.** At the time, the largest randomized trial of entrepreneurship training ever run was America's GATE project. It assigned 4,197 people who wanted to start businesses to be offered free training or not. People not offered it could still find training elsewhere, so the real difference was modest: about 9 more hours of classes and advice, at about $1,321 per person. Six months later, the trained group was about 5 percentage points more likely to own a business. By 18 months the gap in who currently owned a business was small and no longer clear, and by five years there was no clear difference ([Fairlie, Karlan and Zinman, 2012](https://www.nber.org/papers/w17804)).
**Heavier help is expensive, and nobody has measured what it does.** New York City has funded community groups that help people start worker co-ops since 2015. Its partners often work with a group for several years. In the program's seventh year, a pandemic year, it cost about $3.1 million, and 11 new co-ops formally launched or converted, meaning they got a tax ID, formation papers and permits. Another 21 were in progress ([NYC Small Business Services, 2021](https://www.nyc.gov/assets/sbs/downloads/pdf/about/reports/worker_coop_report_fy21.pdf)). That's about $280,000 for each co-op launched that year, though the program also helps existing co-ops, so it isn't a true cost per co-op. And there's no comparison group, so nobody can say how many of those co-ops would have started anyway.
So light help is cheap and fades, and heavier help might work but hasn't been tested. That's exactly the kind of question a lottery answers.
## The founding lottery
### Who can apply
- **A group of at least three people** who plan to work in the business, with an idea and a rough sense of what it would take. There's no loan application yet.
- **Or a sponsor applying for a group.** Unions, worker centers, community groups and existing co-ops often bring groups together. Many co-ops start that way. They can apply on a group's behalf, and results are reported by who sponsored the group.
- **A short screen, run by the reviewers, not the bank,** checks that the people are real and intend to work there, and that the business is legal. Any government ID is enough, including a taxpayer ID number for people without a Social Security number, or a consular or city ID.
- **One entry per person for the whole pilot.** Groups that share a member, a family, an address, an idea or an organizer go into the draw as one unit. Groups that lose the draw can't re-enter under a new name.
### The draw
The reviewers, not the bank, draw half the groups by lottery, using a method filed in advance. The draw is balanced by industry, region, the founders' wealth and prior earnings, and which sponsor, if any, applied for the group. No one sponsor can enter more than a set share of the groups. As with the support lottery, groups agree to the lottery when they apply, and an independent research ethics board approves the design. Groups that weren't drawn can apply for a loan at any time, and are offered the same help after the year-four measurement if the pilot's help budget has money left. Offering it sooner would give the comparison half help before year four and blur the result the [cost ceiling](#the-cost-ceiling) depends on.
**The draw doesn't use race or gender.** The rule across the model is that race and sex can be used to measure results, never to decide who gets anything, and the draw decides who gets help. Balancing a lottery on them is ordinary research practice. Federal law even requires government-funded medical research to include women and minorities ([42 U.S.C. § 289a-2](https://www.law.cornell.edu/uscode/text/42/289a-2)), and nobody's odds would change: every group has the same even chance. But a court might still treat it as sorting people by race. The Supreme Court reviews government "distributions of burdens or benefits based on individual racial classifications" under its strictest test ([*Parents Involved v. Seattle School District*, 2007](https://www.law.cornell.edu/supremecourt/text/05-908)), including when a policy "may be said to burden or benefit the races equally" ([*Johnson v. California*, 2005](https://www.law.cornell.edu/supremecourt/text/543/499)), and in 2023 it said "eliminating racial discrimination means eliminating all of it" ([*SFFA v. Harvard*](https://www.law.cornell.edu/supremecourt/text/20-1199)). No court has ruled on a research lottery balanced this way, and sharper statistics are unlikely to count as the compelling reason that test asks for. Sex gets a lighter test, and balancing on it would be easier to defend; it's left out so the rule is the same for both. With about 500 groups, a plain draw rarely leaves the halves more than a few points apart on any group that's a sizable share of applicants, so little precision is lost.
- **Wealth and earnings are there for their own sake.** They predict whether a group gets a co-op running, and helping founders without money is what this lottery is for. They're taken from tax and wage records where founders allow it, not only from what they say.
- **Answering is optional.** Founders' race and gender are asked on a separate form, with "prefer not to say," and the answer changes nothing about the group's chances. The form goes to a separate custodian and stays sealed until the draw has been run on coded IDs and its results published. Refusal rates are reported for each half.
- **One draw, no reruns.** The random seed is committed publicly before sign-ups open, and the draw is run once. Mistakes are fixed case by case under a rule filed in advance, never by drawing again.
- **The analysis is fixed in advance.** The main result is adjusted for the same things the draw was balanced on, and nothing else, whatever the draw looks like. Results by race and gender are published as estimates, next to the smallest difference each could detect, which for a group making up a fifth of applicants is about 25 points. So they describe who was reached; they can't settle whether help works better for one group than another. A group counts toward a category if at least half its founders report it; mixed groups are reported as their own category.
- **Never targets.** No group's odds, help or verdict depends on its founders' race or sex, and none of these figures is a goal.
### What the helped groups get, for up to a year
- **A stipend for one founding member** for up to six months, so that one of the group can work on getting the business ready full time. That's how Mondragon did it: it paid one of the founders, not an outsider.
- **A feasibility study** by an outside specialist: is there a market, what will it cost, and can it pay a loan?
- **Legal setup, bylaws and a business plan,** in the group's own language, with interpreters if needed.
- **Specialists come from a vetted panel,** assigned by the reviewers, not picked by the group or by anyone who referred it. No specialist can be a member, a member's relative, or someone who referred the group. For three years they can't sell paid services to a group they helped, the same rule that applies to the support lottery's advisers. No one person or organization can advise more than a set number of helped groups.
- **About $40,000 per group in all.**
### What the other groups get
The same public help any would-be business owner can already get, like the Small Business Administration's free counseling centers, and the bank's published standards. They can apply for a loan whenever they're ready.
### What it doesn't change
Help doesn't lower the bar. Every group applies for its loan through the same underwriting. The help doesn't cover early losses. A co-op that needs money to get through its first years borrows it from the bank at market rates, like any other loan.
### How big
About 250 groups in each half, so $10 million for the help. With groups that size, the lottery has an 80% chance of detecting a difference of about 12 percentage points in how many groups end up running a co-op, for example 25% against 37%. Smaller effects might be missed.
**The money comes from the loan budget.** Of the roughly $300 million set aside for loans, $10 million pays for this help instead, which leaves the pilot's cash cushion and running costs untouched. The pilot's capped help budget is $30 million in all: $20 million after the first loan and $10 million before it ([The Pilot, in Numbers](pilot-spec.md#how-much-money-and-why)). Every dollar counts against the bank in the [money test](pilot-spec.md#the-decision-rule), the same as help after the loan.
## What gets measured
- **Are they running a co-op?** The main result: the share of all groups in each half that have a co-op operating two years after the draw, with at least three members and a minimum level of sales, however it was financed. It's measured from government business records by the reviewers, not decided by the bank. So the result can't come from helped groups simply being coached to pass the bank's own underwriting.
- **Are they still running at year four?** The share of all groups in each half with a co-op still open and paying fairly four years after the draw. That counts every group, so help that pushes weak groups into launching and failing shows up as a cost, not a benefit.
- **What did it cost?** The public cost for each extra co-op the help created, and for each extra co-op still open at year four. At the smallest effect the lottery can detect, about 30 extra co-ops for $10 million, that's roughly $330,000 each. A ceiling is set in advance, [below](#the-cost-ceiling). Above it, the help isn't recommended for any larger program, however many co-ops it creates.
- **Who did it reach?** Founders' prior earnings, wealth, gender, race, whether they were born abroad, and language, in each half, from the optional form.
- **Where else did groups get help?** Groups report other help and funding they used, so any effect from help found elsewhere, or co-ops simply moving from other lenders to this one, can be estimated.
### The cost ceiling
**What counts as the cost.** Everything the public spends in the helped half beyond what it spends in the other half, by year four: the help itself, running the draw and the specialist panel, the help co-ops get after their first loan, and the bank's net result on their loans, losses minus interest earned above the Treasury rate. That's divided by how many more co-ops the helped half has still open and paying fairly ([as the pilot defines it](pilot-spec.md#what-gets-measured)) at year four. Comparing whole halves means nobody has to guess which co-ops were the "extra" ones. The minimum level of sales a co-op needs to count is filed before the draw.
**The ceiling: $400,000 per extra co-op still open at year four,** in today's dollars, rising with inflation. There's no good benchmark, so here is what the number rests on:
- **New York City's program** spent about $280,000 for each co-op launched in 2021 ([above](#what-we-know)). That figure is rough both ways. Some of the money went to existing co-ops, which makes it too high. But it counts co-ops at launch, not still open four years later, and it can't tell which would have started anyway, which makes it far too low as a cost per extra co-op. It shows the scale of what a city has spent, not what the help is worth.
- **Business tax breaks.** The economist Timothy Bartik finds that state and local tax breaks typically cost about $200,000 for each job that wouldn't have come to the area without them. He puts infrastructure and services in struggling places, counting jobs at local suppliers and shops, at $30,000 to $100,000 a job ([Bartik, 2024](https://eig.org/wp-content/uploads/2024/07/TAWP-Bartik.pdf)). A co-op needs at least three members to count, so $400,000 is about $133,000 per member in the smallest one. That's between his two figures. It isn't a like-for-like comparison: most members would have worked somewhere else, and co-ops' main benefit is ownership and a say, not jobs.
**It's hard to meet, and that's said now.** With about $11 million spent before the first loan and roughly $50,000 of later help for each co-op, the helped half needs something like 32 or more extra co-ops still open at year four, out of about 250 groups. That's more than the smallest effect the lottery is built to detect, which is about 30 extra co-ops running at year two, before any have closed.
**Three possible verdicts, set in advance.** With about 250 groups in each half, the estimate will be uncertain, so the report uses a range filed before the draw:
- **Worth it:** even the high end of the range is under the ceiling. Only then is the help recommended for a larger program.
- **Not worth it:** even the low end is over the ceiling.
- **Not settled:** anything else. The report says so and gives the size of trial that could settle it, but it doesn't recommend the help.
**Reported by group, judged as a whole.** The cost per extra co-op is also published by founders' wealth, race and whether they were born abroad, and for struggling areas against the rest. Help that works mainly for low-wealth founders may be worth more than a single national line admits. But the verdict uses the one ceiling, so a favorable slice can't be picked out afterward. The cost is reported again at year six, since the effects of help often fade.
The co-ops from both halves count in the main test, and the main result is also reported without the helped half's extra co-ops. So help can't quietly change what the main test measures.
## Should founders put in money of their own?
Mondragon required it: its founders put in a fifth of the start-up money. Under Italy's Marcora program, which helps workers buy out closing firms, workers put in their own money too, often from their severance pay and unemployment benefits ([Has This Been Tried?](precedents.md)). A stake makes founders care what happens to the loan. But a cash buy-in shuts out workers with no savings, who are the people co-ops most often serve.
**The model's answer: a small stake, payable in work.** The founding members together put in at least 5% of the first loan. They can pay it in cash, in work, or both.
- **Work is valued at the local median wage for the job**, from the government's wage survey, not at whatever pay the founders plan to give themselves.
- **Only hours logged at the time count,** from the date of application, and a specialist from the panel or the reviewers checks them. Hours paid by the stipend don't count.
- **Only work before the co-op is formed can be unpaid.** After that, founders are its employees and are paid at least the minimum wage. Only the difference between the minimum wage and the median wage counts toward the stake ([Owners Are Still Workers](labor-law.md#founders-unpaid-work)).
- **It's a real stake.** Unpaid work is recorded as back pay the co-op owes, ranked behind the bank ([The Founder Problem](founder-problem.md#the-tools)). Founders lose it if the co-op fails.
For a typical first loan of $500,000, that's $25,000 in all. Five founders could meet it with about $5,000 each, or with a few months of evenings and weekends. Three founders would need about $8,300 each, or more hours.
**What the stake does and doesn't do.** It shows commitment, and it makes sure founders have something to lose. It doesn't stop fraud. A group that plans to take the money and disappear gives up only work it never intended to be paid for. The defense against that is elsewhere: loans paid out in stages as the business hits milestones, and the rules in [Guarding the Pilot](pilot-safeguards.md).
## What this doesn't solve
- **It tests help for groups that come forward.** Many workers never think of starting a co-op at all. Sponsors like unions and community groups help reach them, and results are reported by sponsor. But the lottery can't test outreach to people nobody reaches.
- **A year's help may not be enough.** New York's partners often work with groups for several years. If the effect needs longer, this design will understate it.
- **Groups that lose the draw may find help elsewhere.** That makes the helped half look less different, so the result may understate the real effect, as happened in GATE.
- **Four years of survival is a small sample.** Only the groups that launch can survive, perhaps 60 to 90 in each half. So the year-four result will be rough.
- **The work stake still costs time.** Founders who can't spare unpaid hours, because they need every paid hour they can get, still face a barrier. The stipend in the helped half partly makes up for that. Outside it, nothing does.
- **Help only for co-ops will be called unfair competition.** It's a research cost, time-limited to the pilot, published each year, and counted against the bank. That's the honest answer. It won't stop the attack.
- **$10 million is $10 million.** If the help doesn't create more co-ops that last, the pilot will have spent it to learn that.
+244
View File
@@ -0,0 +1,244 @@
---
title: When an Owner Wants to Sell to the Workers
section: Growing the Sector
order: 60
summary: Owners who choose to sell their company to its workers can get a loan from the bank to do it, on a separate track with its own money and its own test, so the sale can't overcharge the workers or muddy the main experiment.
---
# When an Owner Wants to Sell to the Workers
This model buys nobody's company. But some owners *want* to sell to their workers. Most often, they're retiring, have no family member who wants the business, and would rather it stay in the town with the people who built it than go to a competitor or a private equity fund.
American banks already lend for some of these sales, as the evidence below shows. But those are mostly sales to employee stock ownership plans (ESOPs), trusts that come with large federal tax breaks and don't give workers one member, one vote. Sales to a real worker co-op, especially of smaller firms, are harder to finance. Lenders usually want personal guarantees, which a whole workforce can't give ([Why a new bank](pilot-spec.md#why-a-new-bank-not-existing-programs)).
So should [the bank](the-bank.md) lend for these sales? This page's answer is **yes, but on a separate track**, with its own money, its own rules and its own test, and alongside private lenders wherever they'll join in. The evidence for buyouts of healthy firms is among the strongest in the co-op literature. The ways they go wrong are also well known, and every rule below answers one of them.
## The case for it
**Healthy firms sold to their workers survive.** In France, five years after a healthy business is handed over to its workers as a co-op, 90% are still running. For French co-ops as a whole the figure is 79%, and for French businesses generally it's 61%. Even struggling firms taken over by their workers reach 76% (France's co-op federation, cited in a [2025 report to the French National Assembly](https://www.assemblee-nationale.fr/dyn/opendata/RINFANR5L17B1696.html)). That isn't a like-for-like comparison. The general figure includes brand-new businesses, and firms chosen for a handover were already doing well. Still, it's hard to read as a warning.
**Loans for these sales get repaid.** A study of 1,232 bank loans for ESOP buyouts at three large banks, from 2009 to 2013, found only 0.2% a year defaulted in a way that cost the lender money. By comparison, loans to mid-sized companies generally defaulted at 2% to nearly 4% a year over similar periods, though the two figures don't count defaults in quite the same way ([NCEO, 2014](https://www.nceo.org/hubfs/assets/pdf/Default-Study-full.pdf)). So the comparison only suggests something. It doesn't prove it.
**It can grow the sector fast.** Britain created a simple way for owners to sell to a trust for their employees in 2014, with a tax break. By January 2025 there were about 2,000 of these employee ownership trusts, and one estimate puts them at 6% of all business sales in 2024. Most are doing well. Very few have failed, mostly in construction, where profit margins are thin ([Pendleton and Robinson, 2025](https://eprints.whiterose.ac.uk/id/eprint/223994/)).
**It reaches workers the startups don't.** A startup co-op begins with a handful of people. A buyout brings in a whole existing workforce at once, often in trades where new co-ops rarely start.
**A caution about all of this evidence.** None of it comes from exactly this design. ESOPs and British trusts come with big tax breaks, and neither gives workers one member, one vote. Italy's buyouts under the Marcora Law also show high survival, 87% still operating, but most were rescues of firms in crisis, and many were only a few years old when counted ([Has This Been Tried?](precedents.md#italys-co-op-funds-coopfond-and-the-marcora-law)). The evidence shows that selling a healthy firm to its workers can work. It doesn't show that this particular track will.
## What goes wrong
**Workers overpay.** The seller usually knows the business best and wants the highest price. In one American case, the trustee who was supposed to protect the workers approved a price after a half-hour meeting about the valuation, and the workers' plan overpaid for Constellis, a private security contractor, by almost $30 million. A federal appeals court upheld the judgment ([Brundle v. Wilmington Trust, 4th Cir. 2019](https://www.ca4.uscourts.gov/opinions/171873.P.pdf)). In a British case, a founder gave his adviser rosy forecasts, the adviser raised them further, and the managers who had to run the company were given no time to read the terms. The company nearly failed under its payments to the founder and was saved only by renegotiating (Pendleton and Robinson).
**The seller keeps control.** Under Britain's original rules the seller could appoint the trustees who were meant to act for the workers. Britain changed that in 2024. Now the seller and their associates can't make up half or more of the trustees, and the trustees must make sure the price is no more than market value ([Pendleton and Robinson](https://eprints.whiterose.ac.uk/id/eprint/223994/)).
**The debt comes out of the workers' future.** Almost nine in ten British trust sales are paid for by the company itself, out of future profits, over about six years (Pendleton and Robinson). That's the workers' money, paying the seller. Most firms manage it, but it can squeeze out investment and pay.
**Tax breaks get expensive and attract the wrong sellers.** Britain let owners who sold to a trust pay no capital gains tax at all. By 2021–22 the tax break cost £600 million a year, and it was forecast to reach £2 billion by 2028–29. One accounting firm puts that at about 20 times what was originally expected ([Scholes Chartered Accountants, 2025](https://www.scholesca.co.uk/blog/why-did-the-autumn-budget-change-employee-ownership-trusts)). In November 2025 the government cut it back, so that only half of the gain is tax-free ([Charles Russell Speechlys, 2025](https://www.charlesrussellspeechlys.com/en/insights/expert-insights/corporate/2025/employee-ownership-trusts---government-reduces-capital-gains-tax-relief-on-employee-ownership-trusts-in-2025-budget/)). The researchers noted that a big tax break "could attract owners who have little or no interest in employee ownership."
**A trust isn't a co-op, and it isn't forever.** In a British trust, the workers usually don't vote one member, one vote. The company is typically run with an ordinary management hierarchy, and a trust can later sell the company. Nothing like [the lock](the-lock.md) stops it.
**Rescues of failing firms lose money.** Uruguay's public fund lost almost 90% of its loans, mostly on firms that had already failed ([The Pilot, in Numbers](pilot-spec.md#two-warnings-from-real-life)).
## The rules
### Healthy firms, willing sellers, no new tax break
- **Only healthy businesses.** At least three years of profits, with the latest year audited by an outside accountant and every year matching the tax returns. Sales to the seller's own other businesses or relatives don't count toward those profits. The seller must have owned the business for at least three years, and it can't be in bankruptcy or behind on taxes. Rescues stay out, as they do for [the rest of the pilot](pilot-spec.md#who-can-borrow).
- **The owner starts it, in writing.** The bank never approaches an owner, and neither do the advisers or groups the pilot pays. The owner can back out at any time before closing.
- **No new tax break.** The bank's only offer is that it will lend. That keeps out most owners who are only after a tax deal, and it keeps the cost to the public from growing the way Britain's did. An old tax break already covers some of these sales. The model leaves it alone, but counts its cost against the track ([below](#the-tax-break-that-already-exists)).
- **Private lenders go first.** The bank lends alongside a private or community lender, such as a bank, a credit union or a community development lender like the co-op loan funds, that takes at least a quarter of the main loan, on the same terms and in the same place in line. That's demanding, and it's meant to be. In the conversions Project Equity reviewed in 2017, the main loan was usually shared among several lenders, often led by a small co-op loan fund, and some bigger lenders would join only in the safest, first-paid slice of the larger deals ([Project Equity, 2017](https://www.project-equity.org/wp-content/uploads/2023/06/Addressing-the-Risk-Capital-Gap-for-Worker-Coop-Conversions_Strategies-for-the-Field_Project-Equity.pdf)). The bank won't take the riskier slice behind a private lender, because that would put the public's money where the private money should be.
- **The private quarter has to be really private.** It counts only as far as the lender keeps it at risk: not guaranteed or bought in part by a state or federal program, and not sold on, for the whole life of the loan, certified each year. Only new money counts, so a seller's existing lender can't join at a quarter while the sale pays off its old, bigger loan. A lender funded by the bank itself doesn't count. Every lender says in the deal file where its own money comes from, and the bank reports each year how much of the private share came from ordinary banks and credit unions and how much from mission lenders, some of which are partly funded by public programs or charities.
- **The lenders share alike.** A written agreement among the lenders, published with the deal, splits fees, collateral and any early repayment in proportion. The bank or a neutral party manages the loan, so the private lender can't collect first or push a piecemeal sale that suits it, and every step in a default follows [the lock](the-lock.md).
- **When no private lender will join.** The bank can lend alone only after two lenders with no ties to the deal have turned down an equal quarter share on the standard form, naming the terms they would have accepted. Deals the bank makes alone are reported separately, with their own loss rate, and can't be more than a third of the track's loan money. If that cap is reached, the bank stops lending alone and says so. The bank expects many deals to need it, because an equal place in line is a strict rule. A high share of solo deals would say more about how strict the rule is than about whether the co-ops can pay. If private lenders join almost every deal, that's a sign the bank isn't needed for buyouts, and the results will show that too.
### The tax break that already exists
Since 1984, federal law has let an owner put off capital gains tax on selling a company's shares to an ESOP or a worker co-op ([26 U.S.C. § 1042](https://www.law.cornell.edu/uscode/text/26/1042)). The rules:
- **The company** has to be an ordinary "C corporation" whose shares aren't publicly traded, and the sale has to be of its shares, not its assets.
- **The seller** has to have held the shares for three years, and the co-op has to own at least 30% of the company right after the sale.
- **The money** has to be reinvested in shares or bonds of other American operating companies, between three months before the sale and a year after it. Only the part reinvested in that window is covered.
- **The tax** comes due when the seller sells that replacement investment. Giving it away doesn't trigger the tax. If they hold it until they die, their heirs inherit it at its value then, and the tax is never paid. Sellers commonly buy long-term bonds as the replacement and borrow against them, so they get the cash without the tax ([CSG Partners, 2023](https://www.csgpartners.com/esop-resources-news/1042-rollovers-capital-gains-taxes-esop); [26 U.S.C. § 1014](https://www.law.cornell.edu/uscode/text/26/1014)).
**The limit to C corporations is weaker than it looks.** An owner of an S corporation, the more common kind among small companies, can drop that status before the sale, even the day before ([Wiener, 2021](https://jrwiener.com/updated-section-1042-tax-benefits/)). The cost is waiting five years to switch back ([NCEO](https://www.nceo.org/employee-ownership-faq/who-qualifies-for-the-section-1042-tax-deferral-esop)), and a locked co-op is a C corporation anyway ([How a Locked Co-op Is Taxed](taxes.md)).
**It has barely been used for co-ops.** A guide for owners posted in 2011 said that "as far as the we can determine, that provision has never been used" for co-ops, though sales to ESOPs use it routinely ([Britton & Stewart, NoBAWC](https://nobawc.org/wp-content/uploads/2011/11/Selling-to-Your-Employees-through-a-Worker-Cooperative.pdf)). That's old and hedged, and nobody seems to have counted since. The authors think a company can turn itself into a co-op and buy back the owner's shares, and still count as a sale to a co-op. That hasn't been tested either. A bank that lends for co-op buyouts could make the break far more common, and that makes its cost part of what the track costs.
**The co-op takes on risks too.** To give the seller the break, the co-op has to sign a statement agreeing to two federal penalty taxes (§ 1042(b)(3)):
- **10% of the sale price** if it sells the shares it bought within three years and its holding falls ([§ 4978](https://www.law.cornell.edu/uscode/text/26/4978)). A forced sale after a default in those years could trigger it.
- **50%** if any of those shares, or the benefit of them, goes to the seller, their relatives or anyone owning a quarter of the company ([§ 4979A](https://www.law.cornell.edu/uscode/text/26/4979A)). A seller or relative who works there can still be a member on the same terms as anyone else, but shouldn't get any of the shares the seller sold ([Britton & Stewart](https://nobawc.org/wp-content/uploads/2011/11/Selling-to-Your-Employees-through-a-Worker-Cooperative.pdf)). How that fits with members' capital accounts is a question for each co-op's lawyer.
A share sale also brings every old debt and lawsuit of the company along, and the co-op doesn't get the higher tax value on its equipment that buying the assets would give it, which means more tax later.
**What the model does about it:**
- **It doesn't forbid it, or reward it.** The model doesn't change tax law in either direction. The bank can't refuse or reprice a deal because the seller takes the break, or because they don't.
- **The co-op decides with its own lawyer.** Before it signs the statement, the co-op gets written advice from its own lawyer on the penalty taxes and the share-sale risks, paid from the help budget. Any penalty tax the co-op owes because of the seller's break comes out of the seller's paid-last share first. The co-op can sign only at closing, with a copy to the bank. Signing later without telling the bank breaks the loan terms.
- **The seller proves it.** A seller who takes the break sends copies of the election and the proof of reinvestment within 15 months of the sale. Part of their paid-last share is held back until they arrive. The reviewer checks them.
- **It's counted as tax not collected, in the pass test.** The cost is the federal tax the seller would have owed on the sale without the election, at the seller's own rate, leaving out gain another rule already excuses, such as the small-business stock exclusion ([§ 1202](https://www.law.cornell.edu/uscode/text/26/1202)). It's counted as never paid. It goes into the track's [money test](#why-a-separate-track) like any other cost, because the track is what makes it happen. Lost state tax is reported beside it but not counted, since it isn't federal money.
- **The deal file shows it next to the price.** Workers and their advisers see what the seller's tax saving is worth before the vote. They can bargain over it. The model doesn't set a split. It's also in the seller's line in [Where the money went](#who-buyouts-are-compared-against).
**How big it could be.** Take the track's typical $2.5 million sale, where about $2 million is paid at closing and the rest through [the seller's share](#the-seller-shares-the-risk). If the owner started the business, nearly all of the price is gain. At the top rate of 23.8% (20% capital gains plus the 3.8% investment income tax), the tax put off on the closing money is about $475,000. It's up to about $600,000 if the seller reinvests other money to cover the rest. That's a third or more of the bank's roughly $1.5 million loan. If every one of 50 to 60 deals used it, that's roughly $25 million to $35 million, a quarter or more of the track's money. If many sellers take the break, the track will probably fail its money test. That's the honest answer to whether these buyouts paid the public back. Nobody knows how many will take it, because almost nobody has.
### A fair price
- **Appraisers come from a panel, in rotation.** An independent appraiser, drawn in turn from a panel the bank keeps and paid by the bank, values the business under standard professional rules. The value is what the business is worth on its own, to a buyer who'll run it as it is. It doesn't include the premium a competitor might pay to combine it with their own business, since the workers can't get that value out of it.
- **Profits are checked, not taken on trust.** Before the appraisal, an outside accountant restates the profits as they'd look under new owners. The owner's own job is counted at a normal salary for that work. Upkeep and equipment the owner put off are counted as costs. One-off gains are taken out.
- **The seller can challenge it.** The seller can order a second appraisal from the panel, at the seller's own cost, so asking again isn't free. If the two are within 10% of each other, their average is the final figure. If they're further apart, a third panel appraiser settles it, and that figure is final. The third appraisal is paid for by whichever side's appraisal it lands further from, and if it lands nearer the seller's, the bank also pays back the seller's second appraisal. The price can't be higher than the final figure.
- **Appraisers are checked too.** One deal in ten, picked at random, is re-appraised after closing by a different appraiser. Appraisers whose values run consistently high are dropped from the panel.
- **Everything the seller gets counts as the price.** For five years after the sale, every payment to the seller, their family or their other companies counts toward the price: salary, consulting fees, rent, royalties, payments for not competing. The only exception is a payment set at a going rate by an independent appraiser. If the seller keeps the building, the co-op gets a lease of at least ten years at appraised rent and the right to buy it at appraised value. Patents, brands, customer lists and a normal level of working cash all come with the business.
- **The seller tells the truth, in writing.** The seller discloses in writing any big customer or contract that's at risk, any lawsuit, and anything else a buyer would need to know. If the disclosure turns out to be false, the co-op can cut what it still owes the seller by the damage, and sue for more.
- **The workers get their own adviser**, chosen by the workers (or their union, if they have one) from a list the bank approves, and paid a flat fee from the buyout track's own budget, the same whether or not the sale goes through. The workers can replace the adviser by majority vote. Each adviser's record, how many of their deals closed and how those firms fared, is published, and advisers aren't handed more work for closing more deals. The adviser explains the price, the debt and the risks in plain terms. The workers get at least 30 days with the full terms before they vote.
### The seller shares the risk
- **At least 20% of the price is paid to the seller over time**, and only after the bank's loan payments are made. If the price was too high and the business struggles, the seller's payments stop before the workers' jobs do.
- **Nothing is paid on it for the first two years.** Interest builds up, but the seller's payments start only in year three. The workers here put almost nothing in up front, so the seller's waiting share, a loan paid last, is what stands between the lenders and a loss. It isn't the same as workers' own money. The SBA doesn't make sellers wait at all, but it counts a seller's loan as part of the buyer's own stake only if nothing is paid on it for the whole life of the SBA loan ([SBA SOP 50 10 8.1](https://legacy.sba.gov/sites/default/files/2026-09/SOP%2050%2010%208.1%20Technical%20Policy%20Updates%20effective%2010.1.2026.docx)). Two years is far short of that, so this is a loan paid last, not a stand-in for workers' cash. Two years is meant to cover the first, riskiest stretch after the sale without asking a seller to wait ten. After that, the lenders' protection gets thinner each year as the seller is paid. The coverage test and the forecast cut below only partly make up for it: they check that the business can pay, but they aren't a cushion if it fails. What the lenders do have in front of them is a smaller loan. The main loan here is about 80% of the price, where an SBA lender can lend up to 90%. Anything else paid to the seller in those two years, such as adviser's fees, waits with it and is paid last too.
- **The seller's loan has limits of its own.** Its interest rate can't be higher than the bank's own rate on the deal plus 4 points. That's a higher rate than the bank gets, because the seller is paid last and waits, but it's capped: any interest above the cap counts as part of the price, so it can't be used to slip past the appraisal cap. The deal file states what the seller's loan is really worth at a going rate for loans paid last, so everyone can see how much of the price is money now and how much is a promise. The loan is due within ten years of the sale, extended by any time its payments had to wait (below), with interest still building at the capped rate. A cut for a wrong forecast (next point) applies to the interest that has built up too, not just the original amount.
- **That share shrinks if the forecast was wrong.** If checked profits over the first three years average more than 20% below what the appraisal assumed, the price is treated as too high by the shortfall beyond 20%, and that amount comes off what's still owed to the seller, up to all of it. On a $2.5 million sale with $500,000 still owed, profits 30% below the forecast cut $250,000 (10% of the price); profits 40% or more below cut the whole $500,000. The cut is measured against the whole price, not just the seller's remaining share, because a seller who knew a drop was coming was paid most of the inflated price in cash at closing. There's no cliff, so a few dollars of profit either side of the line don't swing the seller's payout, and nobody on either side has much reason to nudge the books.
- **Neither side can tilt the test.** Profits for the test are counted under the same accounting rules as the pre-sale restatement, with all pay, members' and managers' alike, held at pre-sale rates per unit of work (after inflation), and upkeep at its normal level. So the co-op can't shrink the seller's share by raising pay, hiring ahead, skipping repairs or moving costs into the test years. If profits jump back in year four, costs shifted into the test years are added back. The seller gets the audited accounts and the calculation, and either side can send a dispute to an accountant from the bank's panel, whose answer is final. The seller's payments due in year three wait in a separate account until the test is settled.
- **Only half of an industry-wide fall is forgiven.** A fall that hits the whole industry is measured by a public index, chosen for each industry in the lending standards filed before the first loan, not picked deal by deal. Which industry a firm is in is fixed at signing, from the industry code on its last tax return before the sale. Half of that fall is taken out before the test, and half stays with the seller. A seller shouldn't carry all of a recession they couldn't have known about, but the workers shouldn't carry all of it either. The adjustment works both ways: if the industry grew, the forecast is raised by half the growth before the test.
- **The seller is protected like a lender, and only like a lender.** The seller's share carries a legal claim on the business's assets, behind the bank's, and the right to be paid. It carries no say in how the co-op is run. The seller signs a written agreement with the bank and the private lender to be paid behind them, and can't demand early repayment or seize assets while the bank's loan is unpaid. If the seller sells the loan to someone else, every rule here, the wait, the cut and the pay protection, binds the new holder.
- **Pay isn't cut to pay the seller.** In any year the co-op cuts its members' pay rates below where they were before the sale (after inflation), the seller's payments wait. They're owed later, not cancelled, and the due date moves back by the same time, so the waiting payments don't pile up into one lump at year ten.
- **The debt has to fit the business.** The bank lends only if the business's checked cash flow, what's left after normal upkeep spending and taxes, covers all the loan payments, the bank's, the private lender's and the seller's, plus the cash members need to pay tax on their profit shares and the capital-account payouts that will come due ([Member Capital Accounts](capital-accounts.md#where-each-years-profit-goes)), with a real margin to spare: checked cash flow in each of the last two years, and on average over the last three, at least 1.25 times the payments in the heaviest year of the schedule. That's usually year three, when the seller's payments start on top of everything else, with the interest that built up during the wait. The SBA asks the same 1.25 times for a co-op or ESOP buying a business, under the rules that take effect in October 2026, measured on the last year or the average of the last two, with no credit for forecasts. It counts a seller's loan too, unless nothing is paid on it for the whole life of the SBA loan, and it treats a loan with a payment holiday as if it were repaid over ten years or less ([SBA SOP 50 10 8.1](https://legacy.sba.gov/sites/default/files/2026-09/SOP%2050%2010%208.1%20Technical%20Policy%20Updates%20effective%2010.1.2026.docx)). So neither the margin nor counting the seller is unusual. The bank's test is stricter in three narrower ways. It counts cash left after upkeep and taxes, where the SBA starts from earnings before interest, taxes and depreciation, adjusted as the lender judges prudent. It tests the heaviest year of the schedule, not an average. And it counts the cash members need for tax on their profit shares and their account payouts. The SBA also caps all the debt for a purchase at the business's appraised value, as this track does. The margin is part of the lending standards filed before the first loan ([Making It Last](durability.md#what-a-new-board-cant-change)).
### The workers decide
- **Who votes.** Every worker on the payroll 12 months before the owner applied, and still there, can vote, including part-time workers and agency temps who work there regularly. The seller and their family can't. Nobody can be laid off, reclassified or offered money tied to how they vote in that time.
- **How they vote.** By secret ballot, run by an independent election body, with a neutral ballot and after the 30 days with the full terms. The sale needs a majority of *everyone eligible*, not just of those who turn out.
- **Unions keep their place.** A union contract carries over to the co-op. Becoming a member doesn't take away anyone's right to organize.
### Then they govern
- **Everyone becomes a member unless they say no.** At closing, every worker is enrolled as a member unless they opt out in writing. The buy-in is kept small and can be paid by payroll deduction, so no one's shut out for lack of savings. Workers who opt out can't be fired for it, and can join whenever they like.
- **The membership rule, with a fair start.** New hires follow [the lock](the-lock.md)'s usual rule: membership after at most 12 months. For the first three years, workers who were there before the sale and chose not to join don't count against the rule that members must do three-quarters of the work hours. After that, the normal rule applies, as it does for every co-op the bank funds.
- **One member, one vote, from day one.** A converted firm meets the full [co-op definition](worker-ownership.md), unlike a British trust. It can keep managers, but they answer to a board elected by the members. The first board election happens within 90 days of closing.
- **The seller's control ends at the sale.** The seller can stay on as a paid adviser for up to two years, and the members can end that by vote at any time. If the seller works there, they can join as a member, with one vote like anyone else. Neither they nor their family or associates can sit on the board, hold a special share or choose who governs.
- **Paying off the debt builds the members' accounts.** Loan payments come out of profit, but they aren't a cost in the co-op's books. So the profit that pays down the loan is still credited to members' [capital accounts](capital-accounts.md) under the usual split, and paid out on the usual schedule. The workers who pay for the business build up a claim on it.
- **Help with the change.** Moving from one owner to a members' democracy is hard. The French parliament's report calls support and training for newly elected leaders essential. Every converted firm gets the standard help new co-ops get, including governance training for its elected leaders. Half, chosen by lottery, also get the intensive help that [the support lottery](pilot-spec.md#the-support-lottery) tests. Both are paid from the buyout track's own budget.
- **Who benefits is published.** Who became a member, who sits on the board and how profit shares are split, by race, gender and job, the same as for [the startups](pilot-spec.md#what-gets-measured).
### Locked, like every co-op the bank funds
A converted firm accepts [the lock](the-lock.md) and stays a co-op forever. This is the biggest difference from Britain's trusts. The seller gets a fair price once. After that, nobody can cash in the business again. Every seller signs knowing that.
The lock costs the seller something too, and they should know it. If the co-op fails while the seller is still owed money, the business can't be sold whole to an ordinary buyer. It can only go to another co-op, or be sold off in pieces. So the seller will likely get back less than from a failed conventional firm. That's one more reason to set the price where the business can carry it.
## Why a separate track
Buyouts would make the pilot's main result look better than it deserves. A business that has already survived for years is far more likely to keep surviving than a new one. If buyouts were mixed into the main test, the co-ops' survival would look better whether or not worker ownership had anything to do with it. That's why [the pilot](pilot-spec.md#who-can-borrow) already scores existing firms separately.
So buyouts get:
- **Their own money.** $100 million, set aside in the same one-time charter, separate from the $350 million for new co-ops. That raises taxpayers' worst case from $350 million to $450 million, and the model says so. $12 million of it pays for the workers' advisers, the checks on deals that don't close and the help after a sale ([What the help costs](#what-the-help-costs)). Buyout loans can never draw on the startup money or the startup help budget, so they can't shrink the main test.
- **Their own limits.** The same $3 million lifetime limit, which covers every business owned by the same seller or group, so one owner can't split a company into pieces to get around it. The same caps on industries and places apply. With a private lender's share and the seller's share on top, that means companies priced up to about $5 million: small and mid-sized firms. Bigger sales can use the ESOP market that already exists.
- **Their own test, judged at year 12 with the rest.** It has two parts, and both must pass:
1. **The money.** The buyout money has to earn at least the Treasury rate after all losses and costs, including the advisers' budget, the appraisals, the track's share of running the bank, and the federal tax sellers put off under the [old tax break](#the-tax-break-that-already-exists). Its losses can't be clearly higher than the loss rate the bank publishes before its first buyout loan ([below](#who-buyouts-are-compared-against)). Deals the bank made alone count, and so do loans restructured to keep them alive, under the same rules as the startup test ([Keeping zombies alive](pilot-safeguards.md#keeping-zombies-alive)).
2. **The pay.** The test follows everyone who was on the payroll at the sale, members or not, whether their firm is still open or not. Their median pay per hour, after inflation, can't have fallen below what it was over the last two audited years before the sale. Workers whose firm closed count at what linked government wage records show they earn now, and as below the line where no record can be found. A track that gets repaid by cutting pay, or by letting the lowest-paid go, hasn't passed.
The two tracks pass or fail on their own. A good buyout record can't rescue a failing startup test, and a bad one can't sink a passing one.
### Why not just use the SBA?
The SBA can already back a loan for a co-op to buy a controlling share (51% or more) of a business. But its rules, including the revision that takes effect on October 1, 2026, treat a co-op worse than an ESOP in two ways that matter for a buyout ([SBA SOP 50 10 8.1](https://legacy.sba.gov/sites/default/files/2026-09/SOP%2050%2010%208.1%20Technical%20Policy%20Updates%20effective%2010.1.2026.docx)):
- **Cash up front.** Buyers in a change of ownership have to put in at least 10% of the cost themselves. An ESOP buying control is exempt. A co-op isn't. The 2026 rules let a lender cut or waive the cash for an "owner buyout," which can include current employees buying the business as individuals, but only if the buyers already have enough cash and working capital. A co-op buying control is its own category, with no waiver. And workers who bought as individuals would each be owners who have to guarantee the loan, which is the next problem. A seller's loan can cover up to half of that, but only if nothing at all is paid on it until the SBA loan is repaid. So the workers would still have to find at least 5% of the project's total cost in cash, including fees and any working capital, and more if the seller won't wait that long. For a $3 million business, that's $150,000 or more, from people most of whom don't have it.
- **Someone has to guarantee it.** The SBA's rule is that owners of 20% or more guarantee the loan. For an ESOP, the rules say plainly that the workers don't have to. For a co-op there's no such exception, and a second rule applies: if nobody owns 20% or more, which is normal in a co-op with more than a handful of members, "at least one of the owners must provide a full unconditional guaranty." That means asking one worker to promise their house and savings for everyone's loan. Lenders have long asked the same of co-ops in practice, and co-op advocates and a House committee have pointed to it as a reason co-ops have rarely used the SBA since Congress opened it to them in 2018 ([The Pilot](pilot-spec.md#why-a-new-bank-not-existing-programs)).
Since the 2026 rules, those two are close to the whole gap. The SBA now has a category for co-op purchases, asks them for the same margin as everyone else, and doesn't require an extra earnings review. For a $3 million business, what's left is about $150,000 of cash, roughly $5,000 each for 30 workers, and one member's signature. A narrower fix could close that: the rule change above, or a small public fund that gives the cash stake as a grant, or lends it on hold for the life of the SBA loan, which the SBA counts as the buyer's own money. The case for putting $100 million into this track rests on those fixes not coming, or on the SBA route still failing co-ops once they do. The track's yearly reports are meant to show which.
Until then, the bank's track replaces both. The seller's waiting share, paid last, takes the place of the workers' cash. The bank lends without personal guarantees, relying instead on the seller's share, the coverage test and a private lender taking part, and pricing what's left of the risk into its rate. That's a real risk the SBA's rules are there to guard against, and the track's loss rate is how it's judged ([below](#who-buyouts-are-compared-against)).
To make sure the bank isn't just taking deals the SBA would have done anyway:
- **Its rates are published next to the SBA's.** Each year, the bank publishes its average rate on buyout loans alongside the typical rate on matched SBA change-of-ownership loans, taken from the SBA's own loan data. The bank will usually charge more. The gap is roughly what doing without the workers' cash and a worker's house costs, along with the bank's own costs.
- **Ordinary lenders have to say no first.** Before the bank lends, the co-op must have asked two lenders with no ties to the deal and a real record of lending for business purchases, one of them a conventional lender and not only the SBA route, and been turned down on a standard form that says why. The forms are published with the deal file.
- **It counts its own overlap.** The bank publishes each year how many of its deals could have met the SBA's terms, by a definition fixed in the lending standards before the first loan.
- **It asks for the SBA fix first.** Both rules are the SBA's own policy, not written into law. Its regulation says only that owners of 20% or more "generally" guarantee ([13 CFR 120.160](https://www.ecfr.gov/current/title-13/section-120.160)). So the model asks the SBA to give co-ops buying control the same two exemptions ESOPs already have, and Congress if the SBA won't. If that happens, the buyout track lends only where the SBA route still can't serve the deal, says each year how many that is, and returns money it no longer needs to Treasury.
### Other public help that already exists
The SBA isn't the only public program that can help. Two others already allow worker buyouts, in pieces. Both are as of 2026, and both can change:
- **State programs paid for by Treasury.** The State Small Business Credit Initiative (SSBCI) gives states federal money to share the risk on small business loans: by buying part of a loan, guaranteeing part of it, or putting up cash to cover a shortfall in collateral. Its rules generally bar using that money to buy out an owner. But they make an exception for a sale to a worker co-op, ESOP or employee trust, as long as the workers end up owning a majority. The private lender has to keep at least 20% of the risk on each loan, and the borrower's own money doesn't count toward that. Treasury's rules ask for personal guarantees only in one narrow case, a loan to a company that just owns the building the business uses. Each state designs its own program, usually with the lender enrolling the loan, and not every state's is open to buyouts. The current round is mostly one-time money from 2021, released in stages, and much of it may be committed before the bank lends ([Treasury guidelines, 2024](https://commerce.mt.gov/_shared/business/MTSSBCI/docs/forms/SSBCI-Capital-Program-Policy-Guidelines-Revised-10.4.24.pdf); [Treasury fact sheet, 2023](https://home.treasury.gov/system/files/136/SSBCI-ESOP-Fact-Sheet.pdf)).
- **USDA's rural business guarantees.** The Department of Agriculture guarantees business loans in rural areas. It asks for personal guarantees from anyone who owns 20% or more of the borrower, at least in proportion to what they own. In a co-op with more than a handful of members that's usually nobody, but USDA can also ask smaller owners to guarantee when it judges the risk high enough, and it can grant exceptions. Its loans for turning a business into a co-op or ESOP are capped at $600,000 and 10 years, and must come within five years of the first transfer of ownership, so they cover only a slice of a typical deal ([7 CFR 5001.204 and 5001.140(d)](https://www.ecfr.gov/current/title-7/subtitle-B/chapter-XLII/part-5001)).
Neither gets around the SBA's rule. An outside guarantee fund doesn't meet it either, because the SBA asks for a guarantee from one of the *owners*. Where these programs do exist, the bank's track uses them, carefully:
- **The state gets asked first.** Where a state's program is open to buyouts, the private lender is asked in writing to enroll the loan before the bank lends. If the state says no, or hasn't answered within 45 days, that counts as a no, so a slow agency can't run out a seller's clock. The bank steps back only if the private lender plus the state can do the whole deal with no personal guarantee, no more cash from the workers and no higher total cost. Deals it steps back from are reported, and followed, as a group of their own.
- **Never with a personal guarantee.** The bank never joins a deal where any program requires a member to guarantee the loan personally.
- **Help is counted as the public's money.** State or USDA help can sit alongside the bank's loan, for example to cover a collateral shortfall. It never counts toward the private quarter. The track's loss rate is measured before any state or USDA recovery, so moving a loss onto another public program can't make the bank look better. Guarantee fees and any payouts go into [the deal's ledger](#who-buyouts-are-compared-against) and the money test. And each deal's file shows the total public money and risk in it, across every program, not just the bank's.
- **It's all published.** Each year the bank reports how many deals used a state or USDA program, how many the state turned down and why, and how many the bank stepped back from. Where Treasury's and USDA's deal records allow, co-op buyouts they supported elsewhere are reported next to the bank's own.
- **It asks for these fixes too.** Alongside the SBA change, the model asks Treasury to encourage states to open their programs to worker buyouts, and USDA to raise its $600,000 cap for conversions. Where they do, the track shrinks, as it would for the SBA fix.
These programs show that public help for worker buyouts already exists, state by state and in pieces. What the track can show is whether one lender, with the same rules everywhere, reaches good deals the patchwork doesn't.
### What the help costs
An earlier version of this page set aside $5 million for help. Counted properly, that was too little.
The best public benchmark is the ESOP sale. The National Center for Employee Ownership says setting one up costs "a low end of $200,000 to $500,000 for most deals," and that an outside trustee adds costs "in the mid-five figures per year" ([NCEO](https://www.nceo.org/employee-ownership-faq/how-much-does-setting-up-an-esop-cost)). The sales here are smaller than most ESOP deals, around $2.5 million, and a co-op doesn't need an ESOP's yearly trustee or yearly valuation. So this model's figures sit below NCEO's. They're its own estimates, not NCEO's:
- **The appraisal**, $15,000 to $35,000. NCEO puts a first ESOP appraisal in the "low five figures and up."
- **The profit check**, the outside accountant's restatement of profits, $10,000 to $30,000. No public price was found for firms this small, so this is a guess.
- **The workers' adviser**, $30,000 to $60,000 for the whole sale. That's less than NCEO's figure for a trustee, because the adviser's job is one sale, not years of legal duty as a fiduciary.
- **Help after the sale.** $15,000 of standard help for every firm, and about $70,000 of intensive help for the half who win the lottery ([The Pilot, in Numbers](pilot-spec.md#the-support-lottery)). That's about $50,000 a firm on average.
- **Deals that fall through.** Some owners will get an appraisal and walk away, or the workers will vote no. Nobody knows how many. This model assumes one and a half to two deals get as far as an appraisal for every one that closes.
Who pays for what:
- **The seller pays for the checks.** Before the appraisal, the seller puts down a deposit that covers it and the profit check, a fixed amount set in advance that doesn't depend on the value found. It's kept at closing, since these are costs of selling. If the seller walks away, it's kept too, so an owner can't get a free valuation to use in some other sale. It's paid back only if the workers vote no or the bank turns the deal down. If the seller then sells the business to someone else within two years, they repay it. Nothing is added to the workers' loan, so the debt stays within the appraised value.
- **The public pays for the rest:** the workers' adviser, the checks on deals that fall through for reasons that aren't the seller's doing, the re-appraisals in [one deal in ten](#a-fair-price), and the help after the sale. Deposits that are kept go back into the help budget.
- **If money runs short, fewer deals, not thinner help.** Each deal's help is set aside when its loan is made, the same rule [the pilot](pilot-spec.md#the-support-lottery) uses.
On those numbers, the public pays roughly $110,000 to $240,000 for each sale that closes. So $12 million is set aside. That covers the 55 to 60 sales the loan money allows unless costs run near the top of the range, where it covers only about 50. In that case the help budget, not the loan money, sets the number of deals, and any loan money left unlent goes back to Treasury.
Because help is given to only half the firms by lottery, the results can show something about what it does. With about 30 firms in each group, that's only a rough signal, and it's reported alongside the startup lottery, not as a test of its own.
**The money test counts all of it, with no exceptions.** Help is a cost the track has to earn back, just as it is in [the startup test](pilot-spec.md#the-support-lottery), and that makes the test hard to pass. A rough guide: loans go out over the first few years and are paid back over ten, so by year twelve each dollar lent has been outstanding for about five years on average. At 2 to 3 points more than the Treasury rate, $88 million lent that way earns something like $9 million to $14 million more than Treasury would have. Out of that come losses, $12 million of help and the track's share of running the bank. So the track passes only if its losses are low and its help costs sit near the bottom of the range. That's said now, before the first loan, so a failure can't be explained away afterward.
### How many deals
After the $12 million for help, $88 million at about $1.5 million a deal, half the limit, would fund roughly 55 to 60 buyouts, and the help budget may hold it nearer 50. With a private lender and the seller's share on top, that typical deal is a business priced around $2.5 million. That's too few for the tight survival comparison the startup test uses. So survival is published and compared, but as evidence, not as a pass-or-fail line. The terms of every deal are published: the price, the appraisals, the loans and the seller's share. So is what happened to them: every cut to a seller's share, every year payments had to wait, and the losses on deals the bank made alone, reported apart from the rest from the start.
### Who buyouts are compared against
Three groups of businesses can be followed. None of them makes a clean experiment, so the comparisons describe; they don't prove that worker ownership caused a difference. The reviewer publishes the matching rules, the code and every classification before the first buyout loan, so nobody can pick the comparison after seeing the results.
**1. Businesses bought with government-backed small-business loans.** The SBA publishes every loan in its main program, including the borrower's industry, the loan size and term, the approval year, and whether the loan was paid off or written off. It also marks loans made for a "change of ownership" ([SBA loan data and its field list](https://data.sba.gov/dataset/7a-504-foia)). By this site's count of the SBA file as of June 2026, leaving out cancelled loans, there were about 4,600 to 6,900 a year from 2020 to 2025, and about 13,800 of $1 million or more since 2020.
- **How they're matched.** On industry, approval year, the whole deal's debt (not just the bank's slice) and loan term. A long term usually means the loan bought a building too, and in these buyouts the seller often keeps it. Loans that look like partner buyouts or employee-ownership deals are dropped where they can be spotted.
- **What's compared.** The share of money written off, at the same loan age on both sides. The SBA reports write-offs before anything is recovered afterward, so the bank's losses are counted the same way.
- **The loss rate the test uses.** For change-of-ownership loans of $1 million to $5 million approved in 2015 to 2019, about 1.9% of the money lent, and 3.1% of the loans, had been written off by mid-2026. That's a floor, not a final figure. About 39% of those loans were still being repaid, and the SBA publishes nothing about them beyond that, not even whether they're behind. And in 2020 and 2021 the government made 6 to 14 months of payments on these loans for their borrowers ([15 U.S.C. 9011](https://www.law.cornell.edu/uscode/text/15/9011)), which kept some from failing. So the bank's rate comes from a formula the reviewer fixes in advance: the SBA write-off rate in dollars for the same industries, sizes and terms, at the same loan age, adjusted for the pandemic years in a way that's also published. The reviewer's job is to make sure it wasn't set high, since a high rate is the easy one to beat.
- **Chance.** About 65 loans at a loss rate like that means two or three failures. One more or one fewer moves the rate a lot. So the published rate comes with a margin worked out in advance for how much it could miss by chance alone, and the test fails only if losses land above that margin. That means the test can catch a track that does much worse than SBA lending, but it can't show that buyouts do better. The report says so.
- **The deals aren't built alike.** The bank's loans come after three audited years of profits, with a checked margin over the payments and a seller who waits to be paid behind the bank. SBA loans often have neither. Lower losses may just show that the screen and the seller's share work, not that worker ownership does, and the report says that too.
**2. Businesses sold to an employee stock ownership plan (ESOP).** An ESOP is a retirement plan that owns company stock for the workers. Each such plan files a yearly return with the Labor Department that marks it as an ESOP, and separately as one that borrowed to buy the stock, gives the company's tax number and industry, and counts the plan's members ([Form 5500 instructions](https://www.dol.gov/sites/dolgov/files/ebsa/employers-and-advisers/plan-administration-and-compliance/reporting-and-filing/form-5500/2025-instructions.pdf)). A plan that ends files a final return.
- **How they're matched.** By industry, state and size, starting from the first year a plan is marked as having borrowed to buy the stock, which is the closest the filings come to the date of the sale. ESOPs are expensive to set up and rare in companies this small, so the reviewer first reports how many matches exist. If there are too few, this comparison is dropped rather than stretched.
- **What's compared.** Whether the firm is still open after 5 and 10 years, and whether it's still owned by its workers. A plan's member count includes some people who've left, so it's only a rough measure of jobs.
- **Sales.** A final return doesn't say why the plan ended. The company may have closed, been sold, or merged the plan into another. Coders who don't know which firm is which sort each case using state business records, and every call is published. For survival, a sale counts as neither success nor failure, and then both ways, as in the startup test. For worker ownership, a sale counts as losing it, since that's the usual way ESOP ownership ends and exactly what the lock prevents.
**3. Deals that almost happened.** Owners who applied but whose deals fell through: on the profit test, the payment margin, the appraisal or the workers' vote. They're the closest thing to the same businesses without a buyout. The reviewer follows them through the same records. Where a deal missed a line narrowly, like a vote decided by a few workers, it's close to a coin flip, and the reviewer reports those cases separately.
**Pay and jobs.** Neither public record has pay. The track's pay test uses the co-ops' own payroll and linked government wage records ([The Pilot](pilot-spec.md#what-gets-measured)). The bank applies for access to those records before the first buyout loan. If access is refused, the report says so instead of leaving the comparison out quietly. Pay growth is also compared with pay in the same industry and county from public wage statistics.
**Where the money went.** For each deal, and for the track as a whole, the report adds up 12 years of payments: to the seller, including everything counted as part of the price, interest to the private lender and to the bank, and what members got above their pay before the sale, in wages, profit shares and account balances. Passing the pay test only shows that workers weren't made worse off while they paid for the business. The ledger shows how much they gained, and how much went to the seller and the lenders. The seller's line includes any capital gains tax they put off ([above](#the-tax-break-that-already-exists)).
**What none of this covers.** Most small businesses change hands with no SBA loan and no ESOP, through cash, private loans and seller financing, and those sales leave no public trail. So the comparisons show how buyouts do against two kinds of financed sale and against deals that fell through, not against every sale.
**Expect opposition.** Business brokers and private equity funds, who make money when owners sell, may see a public lender as a competitor. Italy's main employers' association got part of its worker-buyout program suspended by the EU ([Has This Been Tried?](precedents.md#italys-co-op-funds-coopfond-and-the-marcora-law)). Keeping the bank at market rates with no new tax break is the best defense, because it leaves little to call unfair. Critics can still point to the old break, which is why its cost counts against the track. The rest is in [Making It Last](durability.md).
## What this doesn't solve
- **The workers pay for what they helped build.** A fair price is a price for the owner's years of ownership, and the workers pay it out of profits their own work will produce. This model accepts that, because the alternative is taking the business, and it doesn't take anyone's business. The rules keep the price fair. They can't make it free.
- **These loans have less behind them than ordinary ones.** Almost no cash from the buyers, no personal guarantees and a seller who waits two years, not ten. The coverage test and the seller's paid-last share carry the weight that cash and guarantees carry in an SBA loan. The cushion is also thinner than in past co-op buyouts: the main loan here is about 80% of the price, where the conversions Project Equity reviewed had 50–70% in main loans, with other lenders as well as the seller paid after them. If that isn't enough, it will show up as losses above the published rate, and the track fails its test.
- **An old tax break rides along.** Sellers who qualify can put off, and often never pay, capital gains tax. The model counts its cost against the track but doesn't stop it. If many sellers take it, the track will probably fail its money test.
- **The seller always knows more.** Written disclosures, checked profits and a shrinking seller's share make hiding problems costly. They can't make it impossible.
- **A fair price is a matter of judgment.** Appraisals are estimates. The rules make overpaying harder and put the seller's own money behind the price, but they can't make valuation exact.
- **The vote can be pressured.** A secret ballot, a neutral ballot and a majority of everyone eligible help. They can't stop a well-liked owner or manager from leaning on people in the break room.
- **One failure can become a story.** A single buyout that goes wrong will make a better headline than sixty that don't. Publishing the expected loss rate in advance, and every deal's terms, lets anyone check whether a failure was bad luck or bad lending. It doesn't stop the headline.
- **"Forever" is a lot to ask of a business that wasn't founded that way.** Some owners won't sell under the lock, and some critics will say it's wrong to lock a firm its members never chose to lock at founding. Whether the lock should protect the co-op form or only its value is an open question for every co-op, not just these ([Open Problems](open-problems.md)).
- **It may pull good founders away from co-ops.** A founder who expects to sell to their workers one day can start a conventional company, keep full control for 20 years, and then sell at a fair price. That may be a better deal than founding a co-op ([The Founder Problem](founder-problem.md)). For the model's goal, more workplaces owned by the people who work there, it's still a win. But it could leave fewer strong founders for the startup test.
- **Sixty buyouts is a small sample.** The track shows whether the loans can be repaid safely without cutting pay. It can't show much more than that about whether the workers who were bought in end up better off.
@@ -0,0 +1,177 @@
---
title: Member Capital Accounts
section: The Co-op
order: 25
summary: How a co-op member builds real savings from the firm's profits without the firm ever being sold, how those savings are protected, and what they add up to.
---
# Member Capital Accounts
In a conventional company, you build wealth from the business by owning shares and eventually selling them. In a locked co-op, nobody can sell the firm (see [The Lock](the-lock.md)). So there has to be another way for the value you help build to become *yours*. That's what a member capital account is for.
The idea isn't new. The Mondragon co-ops in Spain have used it since the 1950s, and US worker co-ops use it under ordinary federal tax law. This page describes how it works in this model, and how members' savings are protected. Protection matters, because savings held inside your own employer are exactly the kind that can vanish along with your job.
## The basic idea
Every member has a personal account on the co-op's books. It works like a savings account the business holds for you:
- **Each year, part of your share of the profits goes into it** instead of being paid out as cash.
- **It earns interest.**
- **It's paid back to you** on a fixed schedule: each year's credit about five years after it's made, whether or not you still work there.
Meanwhile, the co-op gets to use that money to grow. In effect it's a loan from the members to their own business. That's why Mondragon valued it: a firm that can't sell shares to outsiders can still keep its own earnings working ([Morris, *The Mondragón System*, 1992](https://www.ilsr.org/wp-content/uploads/2013/03/mondragon-3.pdf)).
## Where each year's profit goes
When the co-op makes a profit, it gets split. These are the defaults. Each co-op can change them in its bylaws, as long as it stays within the minimums.
| Share of profit | Where it goes | Why |
| --- | --- | --- |
| **At least 30%** | The co-op's permanent reserve | Nobody can ever withdraw it (see [indivisible reserves](the-lock.md)). It's the firm's cushion and its own source of investment money. |
| **Up to 10%** | The federation's shared fund, if the co-op belongs to one | Pools risk between co-ops (see [The Federation](federation.md)). |
| **The rest, about 60%** | Everyone who worked that year, divided by hours worked | This is the "profit share" from [Worker Ownership](worker-ownership.md). It ramps up with tenure, then caps. The part of a share someone hasn't reached yet goes to the reserve ([How a Locked Co-op Is Taxed](taxes.md)). |
Mondragon's original split was similar: 10% to education and the community, at least 20% to reserves, and up to 70% to members. It later raised the reserve minimum to 50% (Morris, 1992).
The members' share is then split again, between cash now and the account:
- **At least 20% of it is paid in cash right away.** Members owe income tax on their whole allocation in the year it's made, including the part that goes into the account. The cash is there so they can pay that tax. This is how the usual kind of co-op credit works under US tax law; a second kind, taxed only when paid out, is in [How a Locked Co-op Is Taxed](taxes.md#capital-accounts) ([Democracy at Work Institute guide](https://institute.coop/sites/default/files/resources/Internal-Capital-Accounts.pdf)). The default is half in cash.
- **Lower-income members can take it all in cash.** For a low-wage worker, being taxed on money they can't touch can cost them tax credits and health-care subsidies. So any member whose household income is under twice the poverty line can take their whole share in cash, or the standard split, with nothing in between, so nobody chooses how much to invest. They choose before the year starts, and one low year doesn't qualify. All cash is the default, and like everyone they're signed up for a retirement plan outside the co-op ([below](#saving-for-retirement)). Once the Labor Department has answered on pension law, those who take the standard split won't be taxed on the account part until it's paid out ([How a Locked Co-op Is Taxed](taxes.md#capital-accounts)).
- **The rest goes into each member's account.**
Members can also raise their own salaries instead of taking profit, but that doesn't get around the reserve. It takes its share of pay above the going rate too (see [Investing for the Long Run](the-long-run.md#the-rules)).
**A worked example.** A 40-person co-op makes $600,000 in profit after paying everyone's salaries. It puts $180,000 in its permanent reserve and $60,000 in the federation fund. The remaining $360,000 is divided by hours worked. If all 40 members work the same hours, each one's hours earn $9,000. A member at full share gets it all: $4,500 as a check and $4,500 added to their account. A member at half a share gets $4,500, and the other $4,500 goes to the reserve. Profit earned by the hours of workers who aren't members yet is shared the same way, paid in cash, at the share of a member just starting out. Federal tax law is why it works this way ([How a Locked Co-op Is Taxed](taxes.md)).
## How the account grows
- **Interest is set by formula, not by vote.** A current member's account earns the one-year Treasury rate plus two percentage points, every year. It's credited after the co-op's tax on it, since the co-op can't deduct it ([How a Locked Co-op Is Taxed](taxes.md#interest-on-accounts)). Once a member leaves, their balance earns the plain Treasury rate. So does the slice of a current member's account that's protected by [insurance](savings-guarantee.md), since it's no longer at risk. If current members voted on the rate, they could set it to zero on accounts that mostly belong to people who've left. Or senior members could set it high to pay themselves out of the firm. A formula stops both.
- **Why the extra two points.** Money in a current member's account is at risk: it shares in losses and ranks behind lenders. If it only earned what a risk-free government bond pays, members would vote to pay out as much in cash as the rules allow, and the co-op would have less of its own money to invest. Once you leave, your balance stops sharing in losses, so it stops earning the premium. See [Investing for the Long Run](the-long-run.md#the-rules).
- **Losses are shared.** In a year the co-op loses money, the loss is charged partly to the reserve and partly to members' accounts, in the same proportions as profits. Your account can go down, just like shares in any company.
## How it's paid out
- **Each year's credit is paid back after five years.** The money credited to your account for 2030 is paid to you in 2035, the 2031 credit in 2036, and so on, oldest first, each after that year's accounts are closed and checked by an outside accountant ([What the Rules Cost a Small Co-op](compliance.md#what-a-review-can-and-cant-stand-in-for)). Each co-op can set the wait anywhere from three to seven years in its bylaws. It's one of the two ways US worker co-ops already pay out accounts, called a "roll-over" ([Democracy at Work Institute guide](https://institute.coop/sites/default/files/resources/Internal-Capital-Accounts.pdf)). The co-op gets to use each year's credit for years, and nobody's savings are stuck until they retire.
- **The wait is fixed when the credit is made.** A vote to change it applies only to credits made starting the year after the vote, so senior members can't speed up their own money or slow down anyone else's. For credits that aren't taxed until paid, it applies only to profit from years that start after the vote ([How a Locked Co-op Is Taxed](taxes.md#credits-taxed-later-the-deferred-pay-rules)).
- **Leaving doesn't change the schedule.** When you leave, your balance is fixed at the end of that year, after that year's profit or loss is counted, so nobody can quit just ahead of a bad year and escape it. After that it stops sharing in losses, earns the plain Treasury rate, and each year's credit is still paid on its own date.
- **Small balances are paid early, for everyone.** Once anyone's whole balance is under $2,000, member or not, it's paid at the next payout date. That keeps a co-op from tracking small sums for years, and since it doesn't depend on leaving, nobody gains by quitting.
- **There's no penalty for leaving.** Mondragon used to cut the payout by up to 30% for people who quit. This model doesn't, because a core promise of [the tenure cap](worker-ownership.md) is that you can change jobs without losing what you've built.
- **Accounts are capped at one year's pay,** measured as the highest pay in the last three years, so nobody can cut their own pay to get their money sooner. Anything above that is paid out every year in cash, which a member can put in their own retirement account and invest in anything. Nobody should end up with most of their savings tied to the same company that pays their wages.
- **Any payout can be spread out.** Members get 90 days' notice of each payout and can take it at once or in up to 12 monthly payments, whichever works better with the public benefits they get ([How a Locked Co-op Is Taxed](taxes.md#benefits)). For credits that aren't taxed until paid, that choice is made before the year the credit is earned, and the payments all fall in one calendar year ([How a Locked Co-op Is Taxed](taxes.md#credits-taxed-later-the-deferred-pay-rules)).
- **On death, the account goes to the person you name,** your spouse unless you name someone else with their written consent, on the normal schedule.
- **In a divorce, a court can split the account.** The former spouse gets a separate account of their own, with no vote, paid out on the normal schedule.
- **Every year, each person gets a statement** showing each credit, any losses charged to it, and the date it will be paid. Former members get one too. A dispute gets a written answer within 90 days and can go to an outside arbitrator from the federation's list, or to court.
### Why not pay it out when you leave?
Mondragon pays members their accounts when they retire or leave, and so do some US co-ops. This model doesn't, because of a federal pension law. Under the Employee Retirement Income Security Act (ERISA), any program an employer runs that holds back workers' income "for periods extending to the termination of covered employment or beyond" is a pension plan ([29 U.S.C. 1002(2)(A)](https://www.law.cornell.edu/uscode/text/29/1002)). The Labor Department's rule says the same about bonuses "systematically deferred to the termination of covered employment" ([29 CFR 2510.3-2(c)](https://www.law.cornell.edu/cfr/text/29/2510.3-2)). Members are the co-op's employees ([Owners Are Still Workers](labor-law.md)). The model treats profit allocations as an owner's share, not an employer's bonus, but it doesn't stake members' savings on winning that argument. In states where wage law could reach account credits, co-ops don't use accounts at all until the law changes ([Owners Are Still Workers](labor-law.md#is-the-profit-share-pay)).
A pension plan would break the account. Its money has to be held in a trust, apart from the business ([29 U.S.C. 1103](https://www.law.cornell.edu/uscode/text/29/1103)), and the plan can't lend to the employer ([29 U.S.C. 1106](https://www.law.cornell.edu/uscode/text/29/1106)). The whole point of the account is that the co-op uses the money.
**A fixed schedule is designed to stay outside the law's definition, but that isn't settled.** In 1995 the Labor Department said a company's profit-based program wasn't a pension plan because it didn't "expressly provide for a deferral of income to the termination of covered employment." But that program paid nothing to people who quit, and the Department pointed to that. It also warned that a program can still become a pension plan "as a result of surrounding circumstances," such as "an inordinate percentage" of participants being near retirement age when payments arrive ([Advisory Opinion 95-23A](https://www.dol.gov/sites/dolgov/files/EBSA/about-ebsa/our-activities/resource-center/advisory-opinions/erisa-section-3-3-employee-pension-benefit-plan-advisory-opinion-1995-23a.pdf)). Here, people who leave are still paid afterward, on the same dates as everyone else. A court could count that against the design.
**Courts weigh the facts differently.** Two federal appeals courts have found pension plans in programs that called themselves deferred compensation, let employees choose to put off part of their pay, and paid it when they left. The Fifth Circuit did in 2014 ([*Tolbert v. RBC Capital Markets*, 758 F.3d 619](https://www.courtlistener.com/opinion/2683548/brenda-tolbert-v-rbc-capital-markets-corp/)) and the Sixth in 2019 ([*Wilson v. Safelite*, 930 F.3d 429](http://www.opn.ca6.uscourts.gov/opinions.pdf/19a0148p-06.pdf)). Both read "results in a deferral" literally. But the Fifth Circuit's own earlier rule still stands: "the mere fact that some payments under a plan may be made after an employee has retired or left the company does not result in ERISA coverage" (*Murphy v. Inexco Oil*, 1980, quoted in *Tolbert*). In April 2026 the Fourth Circuit held that awards paid eight years after they were granted were a bonus program, not a pension plan, partly because 92% of the payments went to people still working there ([*Milligan v. Merrill Lynch*](https://www.govinfo.gov/content/pkg/USCOURTS-ca4-25-01385/pdf/USCOURTS-ca4-25-01385-1.pdf)). It listed six things courts can weigh:
1. whether everyone takes part, or only some
2. whether it's paid for with money workers would otherwise get now
3. whether money is really set aside
4. whether workers can choose to put payment off until they leave
5. whether it's presented as retirement savings
6. whether the firm's results change what's paid
**The model's argument, and its fallback.** First, nothing is put off until anyone leaves. Each credit is paid on a date fixed when it's made, the same for everyone, whether or not they've left. Members are taxed on it that year as if they'd received it, which counts against any "deferral of income." And it's members' capital in their own co-op, not an employer's plan for holding back pay. If a court treats the credit as pay anyway, the fallback is the Labor Department's rule: a share of profit on top of wages is a bonus for work, and it isn't systematically put off until people leave. That fallback concedes, for pension law only, that the share is a bonus. That sits uneasily with the model's position under tax and wage law that it's an owner's share, not pay ([Owners Are Still Workers](labor-law.md#is-the-profit-share-pay)).
**Two things weaken it.** One is the lower-income option. Credits that aren't taxed until paid, chosen by the member in advance and run under the tax rules for deferred pay, look most like the plans in *Tolbert* and *Wilson*. The other is that nobody loses their account for leaving, so a lot of it is paid to people who've left. If a tenth of members leave each year, about 40% of each five-year credit belongs to someone who's gone by the time it's paid, and about 27% with a three-year wait. It's less by dollars, since people who leave tend to be newer, with smaller credits. In home care, where even co-ops lose about 38% a year ([When People Don't Stay Long](high-turnover.md)), it's most of it. So:
- **Nothing about the payout depends on leaving:** not the date, not the amount, not the small-balance rule.
- **The wait is short and the same for everyone,** at most seven years, not tied to age or retirement.
- **Credits taxed later wait for the Labor Department.** Until the Department answers, co-ops don't offer them. Lower-income members choose between all cash, the default, and the standard split, taxed now ([How a Locked Co-op Is Taxed](taxes.md#credits-taxed-later-the-deferred-pay-rules)).
- **Most of what's paid goes to people still working there.** The federation's software reports each year what share of a co-op's account payouts, in dollars, went to people who weren't working there, counting small balances and payments to heirs. It also reports the share paid to people at or near retirement age. If more than a third went to people who weren't working there, averaged over three years, the co-op shortens the wait for new credits to three years. If it's still over a third after that, it stops making new credits, and the members' share is paid in cash. A third is the model's own number, far looser than the 8% in *Milligan*, so the request to the Department asks where the line is. Stopping new credits doesn't change the status of old ones, which are still paid on schedule.
- **The account isn't written or sold as deferred pay or retirement savings.** The bylaws describe it as members' capital under state co-op law. Retirement saving happens the ordinary way, in a separate plan held outside the business ([below](#saving-for-retirement)).
- **The same rules apply everywhere.** Under ERISA a suit can be brought where the plan is run, where the breach took place, or wherever a defendant can be found ([29 U.S.C. 1132(e)(2)](https://www.law.cornell.edu/uscode/text/29/1132)). A former member who moves can take the case with them, so a rule based on where the co-op sits wouldn't protect it.
- **The first pilot co-ops ask the Labor Department for an advisory opinion on their real bylaws, and the bank pays.** An opinion only covers the co-op that asked, on its own facts, so the bank also asks the Department for a rule any employer could use, for profit-sharing paid on a short fixed schedule. The Fourth Circuit held that the Department acted within its power to define the law's terms when it wrote its bonus rule ([29 U.S.C. 1135](https://www.law.cornell.edu/uscode/text/29/1135)). Other circuits don't have to agree. It doesn't ask Congress for an exemption only co-ops would get.
**The cost is capital.** Money paid back after five years is money the co-op can't keep using for fifteen. So a co-op that wants to invest more has to keep more in its permanent reserve, and that weakens the model's answer to the horizon problem ([Investing for the Long Run](the-long-run.md#who-pays-for-an-investment-and-when)).
### Saving for retirement
Every co-op also offers an ordinary retirement plan, held outside the business, to everyone who works there, members or not. It's a normal benefit, not a replacement for the account. But it matters most for people who don't build savings in the co-op: members of co-ops that have stopped making credits, and lower-income members who take their share in cash. They're mostly the lowest-paid.
- **A plan the federation doesn't run.** Employers that share "a common interest other than having adopted the plan" can't form their own pooled plan, and co-ops in one federation probably do ([29 U.S.C. 1002(43)](https://www.law.cornell.edu/uscode/text/29/1002)). So each co-op joins an open pooled 401(k) run by a provider registered with the Labor Department, which takes on most of the paperwork and legal duties. The federation compares providers and recommends one. No part of the federation, the bank or its software company is paid from plan money, and fees are a flat amount per person, published every year.
- **Everyone is signed up, as the law requires.** A 401(k) plan started since 2023 has to sign up every eligible worker at 3% to 10% of pay, raised by a point a year to at least 10%, unless the worker chooses otherwise. For the first 90 days they can take their own contributions back ([26 U.S.C. 414A](https://www.law.cornell.edu/uscode/text/26/414A)). Co-ops under three years old or with ten or fewer workers are exempt but use the same rules. The default starts at 3%, and changing it or opting out takes one step. When one large company started signing new workers up automatically, 86% took part, against 37% before ([When People Don't Stay Long](high-turnover.md)).
- **The co-op puts in 3% for everyone, not a match.** A match would come out of shared profit and go only to people who can afford to save, so members who opt out because they need the cash would pay for those who don't. Instead the co-op adds 3% of wages for every eligible worker, whether they save or not, and it's theirs at once. That's one of the law's standard "safe harbor" designs ([26 U.S.C. 401(k)(12)(C)](https://www.law.cornell.edu/uscode/text/26/401)). It also spares the co-op the yearly tests comparing what owners and other workers get, which matters because in a co-op with fewer than 20 members, each member holds more than 5% of the vote, and tax law may treat each one as an owner.
- **The federal match.** From 2027, the Saver's Match adds 50% of up to $2,000 a year that a lower-income person saves for retirement, paid by the Treasury into a non-Roth account. It shrinks as income rises and is gone at $35,500 for a single filer, $53,250 for a head of household and $71,000 for a married couple, before inflation adjustments ([26 U.S.C. 6433](https://www.law.cornell.edu/uscode/text/26/6433)). It's paid the year after, only to people who file a tax return and claim it, so the co-op's [benefits check](taxes.md#benefits) includes help filing. Money taken out within about two years reduces it. The default is the ordinary pre-tax option. Many low-paid workers owe almost no income tax, so after-tax Roth savings would often suit them better, but the Treasury hasn't said yet whether Roth savings earn the match.
- **No withdrawals or loans while working there.** SSI doesn't count a retirement fund as savings if the worker has to leave the job to get any of it ([SSA POMS SI 01120.210](https://secure.ssa.gov/poms.nsf/lnx/0501120210)). Hardship withdrawals or loans would probably make it count. So the plan allows neither, beyond the 90-day refund the law requires. Once someone leaves, their balance can be cashed out, so it counts for SSI then, and cashing out early costs a 10% tax penalty. A member on SSI can stop their own savings, though the co-op's 3% still goes in. Balances roll over directly to a new plan or retirement account unless the worker chooses otherwise.
An example: a home care aide who heads a household and earns $30,000 saves 3%, or $900. The co-op adds $900, and the Treasury $450 the next year, so $2,250 goes into an account that nobody can take from her if the co-op fails. She's just under the income where the Treasury's match starts to shrink, so a raise or other household income would reduce it. The co-op's 3% is a cost, paid before any profit is shared, and in thin-margin work that's real money. But it counts as pay under the Medicaid rule for home care ([When People Don't Stay Long](high-turnover.md#when-medicaid-decides-how-much-goes-to-pay)), and it's within the 6% of pay that [the pay rule](the-long-run.md#the-rules) leaves alone.
### Are accounts securities?
Securities law covers far more than stock. Its definition includes any "note," any "evidence of indebtedness," and any "participation in any profit-sharing agreement" ([15 U.S.C. 77b(a)(1)](https://www.law.cornell.edu/uscode/text/15/77b)). An account credit is a promise to pay a set amount, with interest, on a set date. If credits counted as securities, a co-op would need an exemption from registering them, and anything misleading it told members about them could be grounds for a lawsuit.
**The case that they aren't:**
- **Members don't buy them.** Credits come out of each year's profit share, by one split the bylaws set for everyone. The Supreme Court held that a pension plan workers didn't pay into wasn't a security, because an employee "is selling his labor primarily to obtain a livelihood, not making an investment" ([*Teamsters v. Daniel*, 1979](https://www.law.cornell.edu/supremecourt/text/439/551)).
- **Membership isn't like stock.** The Court held that shares in a housing co-op weren't securities. They paid no dividends from profit, couldn't be sold or pledged, gave no extra votes for extra shares, and couldn't rise in value ([*United Housing Foundation v. Forman*, 1975](https://www.law.cornell.edu/supremecourt/text/421/837)). Membership here is the same: one vote per member, and a buy-in of at most $1,000 held apart from the account at its face value, earning nothing ([When People Don't Stay Long](high-turnover.md#joining-costs-nothing-up-front)). The profit share comes from hours worked, not from the membership.
**The case that they might be:**
- **Notes are presumed to be securities.** In 1990 the Court held that a farm co-op's notes to its members and others were securities. It looked at why the co-op issued them and why people held them, how widely they were offered, what the public would expect, and whether anything else protected the holders. It stressed that the co-op used the money for its business, and that the notes were "uncollateralized and uninsured" and would otherwise "escape federal regulation entirely" ([*Reves v. Ernst & Young*](https://www.law.cornell.edu/supremecourt/text/494/56)). An account is also an unsecured promise from the co-op, the co-op uses the money, and the extra two points of interest are there to make keeping it in worthwhile. The [savings insurance pool](savings-guarantee.md) covers only part of it.
- **This model gives up the protection *Daniel* relied on.** The Court said workers didn't need securities law there because pension law already protected them. Accounts here are designed to stay outside pension law.
- **A former member is simply owed money** by a company they no longer work for, and a payout paused for two years becomes a new debt to them (below).
**So the model keeps accounts on the safe side of each test:**
- **Nobody can put money in.** Accounts are filled only from profit shares. No member can choose to leave more in. Lower-income members can take everything in cash or the standard split, nothing in between.
- **Accounts can't be sold, pledged or borrowed against** (below), and they aren't offered as an investment. A co-op can't advertise account interest to recruit workers, and statements describe accounts as "partly covered by a private insurance pool with no government backing," not as insured savings.
- **Members get the facts.** Every account holder, former members included, gets the yearly statement and the co-op's checked accounts. Anything misleading in them could be grounds for a claim either way, so the federation writes the model statements and buys directors' liability insurance for member co-ops as a group. That's a cost [What the Rules Cost a Small Co-op](compliance.md) doesn't count yet.
- **The bank asks the SEC for a general answer.** The first pilot co-ops ask the SEC's staff to confirm they won't treat credits as securities, and the bank pays. A staff letter binds no court, no state and no member who sues, so the bank also asks the SEC for a rule that covers profit allocations paid on a short fixed schedule, as it does with the Labor Department.
- **State law too.** Each state has its own securities law. The bank's checked entry for each state ([Owners Are Still Workers](labor-law.md#is-the-profit-share-pay)) covers it, alongside wage law. California, for example, exempts "credits to a member's capital" in co-ops formed under its co-op law, for voting members, and doesn't count patronage dividends toward its $1,000 limit on what a member puts in ([Cal. Corp. Code § 25100(r)](https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=CORP&sectionNum=25100)). That shows how a state can handle credits. It doesn't settle federal law, and it doesn't cover former members, who no longer vote.
**The fallback has a price.** If credits did count as securities, the nearest federal exemption covers securities a company issues to its employees under a written compensation plan, up to the largest of $1 million, 15% of its assets, or 15% of that kind of security outstanding, each year ([17 CFR 230.701](https://www.law.cornell.edu/cfr/text/17/230.701)). But using it means calling credits pay, in writing. The tax, pension and wage-law positions elsewhere all rest on credits being an owner's share, not pay ([Owners Are Still Workers](labor-law.md#is-the-profit-share-pay)), and the IRS or a state wage agency could point to that document. It also doesn't cover a paused payout that becomes a new debt to a former member, who isn't an employee anymore. So the model doesn't plan on it. It's a last resort, and the conflict is listed in [Open Problems](open-problems.md).
## Protecting the co-op, and the people who stay
A payout system can also be used to strip a failing firm. People who see trouble coming leave first and get paid in full, while everyone who stays absorbs the loss. So:
- **Every payout must pass a solvency test.** No payment may push the co-op below its loan requirements or a minimum cash level set in its bylaws when it's founded. Members can't lower that minimum without the bank's consent.
- **If payouts fail the test, they're paused, all together, for at most two years.** Credits from the same year are treated the same whether their owner still works there or not, and the oldest are paid first when money allows. Paused amounts earn the Treasury rate plus two points, since the people waiting are carrying extra risk. While any payout is paused, **current members get no cash profit share, no bonuses and no raises above the going rate.** Nobody gets paid ahead of the people who are waiting.
- **Credits that aren't taxed until paid follow stricter rules.** Lower-income members' nonqualified credits are paused only if an accountant finds that paying them would put the co-op at risk, not just for the loan terms or the cash minimum. They're paid as soon as it wouldn't, even ahead of the note below. That's an exception to treating the same year's credits the same, because federal rules on deferred pay require it ([How a Locked Co-op Is Taxed](taxes.md#credits-taxed-later-the-deferred-pay-rules)).
- **After two years, a pause becomes a debt with a schedule.** Whatever is still unpaid becomes a note the co-op repays in equal parts over five years, still behind its lenders, and the [insurance pool](savings-guarantee.md) covers former members' insured amounts if the co-op fails. So the worst case for a member who leaves is a long wait: up to seven years for the last credit, plus the pause, plus the note. The co-op's bylaws have to say so in plain words.
- **Losses are spread evenly.** When a loss is charged to members' accounts, it's charged to every year's credits that haven't been paid yet, in proportion, not to the newest or the oldest first. So the timing of payouts can't be used to dodge a loss.
- **Payouts rank behind the co-op's lenders.** In a bankruptcy, members' accounts are paid only after the co-op's creditors. That's the honest cost of being an owner, and it's the main reason for the one-year-of-pay cap. The tax relief on a lost account is slow, too: members were taxed on the credits in full, but can probably deduct only $3,000 a year of the loss, unless Congress passes the fix the model asks for ([How a Locked Co-op Is Taxed](taxes.md#if-the-co-op-fails)).
- **Accounts can't be cashed in early, sold, pledged or borrowed against,** from the co-op or anyone else. That would just be an early withdrawal by another name. It also keeps the account from counting against public benefits' savings limits before it's paid ([How a Locked Co-op Is Taxed](taxes.md#benefits)). The only transfers are to a named person on death and a court's division in a divorce.
- **Savings are insured, up to a point.** Every locked co-op belongs to a members' savings insurance pool, paid for by the co-ops. It covers workers' final unpaid wages, former members' unpaid payouts up to $50,000, and a protected slice of current members' accounts. See [Protecting Members' Savings](savings-guarantee.md).
- **No other savings in the co-op.** A co-op can't take deposits from its members or sell them bonds. A member's only money in the co-op is their account.
**Why this much caution.** Fagor, Mondragon's appliance maker and once the world's largest industrial co-op, went bankrupt in 2013 with about €800 million (about $1.1 billion) of debt ([The Local, 2013](https://www.thelocal.es/20131114/major-manufacturer-fagor-files-for-bankruptcy)), putting 5,600 jobs at risk. Savers who had bought its subordinated co-op bonds, which had been sold as safe deposits, ended up suing ([Wikipedia summary of the Mondragon record](https://en.wikipedia.org/wiki/Mondragon_Corporation)). Shortly before Enron collapsed in 2001, about 63% of its workers' 401(k) retirement savings were in Enron's own stock ([GAO-02-480T](https://www.gao.gov/assets/gao-02-480t.pdf)). It's the same lesson: savings concentrated in the company you work for are the savings most likely to disappear with your job.
## What the account doesn't do
This matters, because it's the core of [the founder problem](founder-problem.md).
**The account holds the profits you earned. It doesn't hold the value of the firm's future profits.** When a conventional firm is sold, the buyer pays for years of its future earnings, and the seller keeps that money. A co-op member's account never includes that part. The future profits go to whoever works there in the future.
That's intentional. It's what the lock is for. But it means a co-op member who helped build something valuable walks away with much less than a conventional owner would, and the model shouldn't pretend otherwise.
## What it adds up to
Here's what a member who joined a successful co-op in its third year has received by year 15, on top of their salary, in four kinds of business. The numbers come from a simple model with these assumptions:
- A three-month probation, the standard bylaws' default ([When People Don't Stay Long](high-turnover.md#a-short-probation)), paid at the starting share. Then a profit share that grows from 20% to 100% over five years.
- 60% of profit goes to members, minus the founding share in the first ten years ([The Founder Problem](founder-problem.md#the-tools)). This member started in year three, so they get a small part of it too. The part of a share a newer member doesn't get yet goes to the reserve, not to other members, as tax law requires ([How a Locked Co-op Is Taxed](taxes.md#the-tenure-ramp)).
- The workforce grows steadily to the size shown, as on the next page.
- Money received later counts for less than money received now. It's discounted at 5% a year, the rate used in the founder study on the [next page](founder-problem.md).
| Kind of business | Profit per worker (after salaries) | Size at year 15 | **What the member received** |
| --- | --- | --- | --- |
| Care work (home care, cleaning) | $3,000 | 40 people | about **$12,000** |
| Main Street (trades, services) | $15,000 | 40 people | about **$60,000** |
| Strong (skilled services, light manufacturing) | $40,000 | 80 people | about **$160,000** |
| Very high margin (a firm like Conservice) | $150,000 | 200 people | about **$600,000** |
In a conventional firm, all of that goes to the owners.
**Be honest about the first row.** Home care runs on thin margins. For a care worker, a co-op offers a real say in how the work is done, and very little wealth. That's why the model has a separate answer for low-margin work: [the floor](the-floor.md).
These are all success cases. A co-op that struggles pays little or nothing. One that fails pays out only what's left after its debts.
@@ -0,0 +1,33 @@
---
title: Capital, After
section: Growing the Sector
order: 80
summary: Outside investment in a co-op is rented, not owned: profit-sharing claims that expire and carry no vote.
---
# Capital, After
Outside investment into a locked co-op doesn't disappear. It changes shape.
**Rented, not enthroned.** An investor funds the company and receives repayment plus a share of profits for the life of the loan. Then the claim **extinguishes**, and the workers hold 100%.
Today's equity is *perpetual*: an original investor's great-grandchildren collect forever off work they never did. This claim *expires*. That's the whole reform, in one word. Capital gets rented.
The returns can be enormous. A profit-share loan into a risky seed-stage co-op might pay 10x or 30x. That's fine. The principle was never *no big payday*. It was **no perpetual claim and no control**.
There is one limit, though. A claim that takes 90% of the profits for 40 years is "voteless and expiring" on paper, but it turns the co-op into a shell. So for co-ops borrowing from the bank, outside claims rank behind the bank's loan, last at most 15 years, and need the bank's approval. See [Guarding the Pilot](pilot-safeguards.md#draining-it-through-investors).
**Be honest about what this is.** A profit-contingent return means the investor eats the loss when things go badly. That is residual risk, which is the economic job we call equity, whatever we print on the paper. Don't claim it "isn't a stake in the work." It is. Claim what's actually true and actually better: *finite, and voteless.*
**The consequence, priced in.** Capital charges for risk. An instrument with equity-like downside and a terminating upside gets funded last, or demands a bigger profit share. Revenue-based financing already exists in today's market and stays niche for exactly this reason. It bites hardest on brand-new ventures and barely at all on mature, profitable firms.
**Investors are buying securities.** A share of profits sold to an investor is a "participation in any profit-sharing agreement," which federal law lists as a security ([15 U.S.C. 77b(a)(1)](https://www.law.cornell.edu/uscode/text/15/77b)). A co-op raising outside money has to register the offering or fit an exemption, and it answers for anything misleading it tells investors. The usual routes for a small business:
- **Crowdfunding:** up to $5 million a year through a registered online portal. Most investors have a yearly limit. If either their income or their net worth is under $124,000, it's the greater of $2,500 or 5% of whichever of the two is larger ([17 CFR 227.100](https://www.law.cornell.edu/cfr/text/17/227.100)).
- **Selling locally:** only to residents of the co-op's own state, under that state's rules ([17 CFR 230.147A](https://www.law.cornell.edu/cfr/text/17/230.147A)), or up to $10 million a year under each state's own rules where it's sold ([17 CFR 230.504](https://www.law.cornell.edu/cfr/text/17/230.504)). Both leave the co-op to state review.
- **Wealthier and experienced investors:** any amount, to investors who meet the federal wealth or income test, plus up to 35 others who are experienced in business ([17 CFR 230.506](https://www.law.cornell.edu/cfr/text/17/230.506)). States can't require these offerings to be registered, so it's often the cheapest route, though not one for a co-op's neighbors.
- **One large investor:** a parent co-op funding a [spin-off](growth.md), or a lender joining the bank, can invest privately without a public offering ([15 U.S.C. 77d(a)(2)](https://www.law.cornell.edu/uscode/text/15/77d)).
Each route costs legal and filing fees, which weigh most on small raises. The federation keeps standard offering documents for these claims, with a standard warning about the risks: the claim can't be sold easily, it ranks behind the bank, and it can be worth nothing. So each co-op doesn't pay a lawyer to write its own. The co-op's directors, often members who earn an ordinary wage, answer for anything misleading in an offering, so the federation's group liability insurance covers them. A co-op that just wants to borrow can also take an ordinary loan with fixed payments, which courts usually don't treat as a security. Members' own [capital accounts](capital-accounts.md#are-accounts-securities) are a different case.
This rule applies only to co-ops. Conventional firms keep conventional equity. That makes the cost-of-capital question an empirical one: [the test](the-test.md) will show whether co-ops can raise enough on these terms, alongside the bank, to grow.
@@ -0,0 +1,154 @@
---
title: What the Rules Cost a Small Co-op
section: The Co-op
order: 32
summary: The model's checks, from audits to the lock-holder's reviews, are scaled to a co-op's size and done once a year in one package, with audits by lot to deter cheating, though even the lighter package costs a small co-op real money.
---
# What the Rules Cost a Small Co-op
Every page of this model adds a check. Someone has to confirm that members were paid the going rate, that the job titles are honest, that the equipment isn't wearing out unreplaced, that nobody is being pushed out before they'd join. Each check makes sense on its own. Together, they could cost a small co-op more than it earns.
That matters for two reasons. Rules a small firm can't afford end up existing only on paper, or they quietly keep co-ops out of small, low-margin work, which is where [the model most wants them](who-gets-loans.md). This page says what's checked, by whom, how often, and who pays.
## What gets checked
Across the model, a locked co-op is asked to do these things every year:
| Check | Where it comes from |
| --- | --- |
| Year-end accounts, closed and checked by an outside accountant | [Capital account payouts](capital-accounts.md#how-its-paid-out), [savings cover pricing](savings-guarantee.md) |
| Every worker paid at least the going rate for their job, by occupation code | [The pay rule](the-long-run.md#the-rules), [Is the profit share pay?](labor-law.md#is-the-profit-share-pay) |
| A sample of timesheets matched to payroll records | [What counts as an hour](taxes.md#what-counts-as-an-hour) |
| Direct care job titles checked, where Medicaid's 80% rule applies | [When Medicaid decides how much goes to pay](high-turnover.md#how-a-co-op-meets-it) |
| Overtime recalculated once the profit share is set | [Overtime and the profit share](labor-law.md#overtime-and-the-profit-share) |
| Pay ratio and pay bands, by job, gender and race | [Who Sets the Pay](pay.md#everyone-sees-the-bands) |
| Warnings, hearings and dismissals, plus an anonymous workload survey | [When a member won't do the work](free-riding.md#when-a-member-wont-do-the-work) |
| Hires, and people let go before they could join | [No pushing people out](high-turnover.md#no-pushing-people-out-before-theyd-join) |
| Spending on buildings and equipment next to how much they wore out | [Running it down](the-long-run.md#running-it-down) |
| Big expansion proposals and how members voted | [When Growing Means Sharing](growth.md) |
| A statement to each member of their account | [Member Capital Accounts](capital-accounts.md) |
| Contracts with insiders, priced like deals with strangers | [The hidden boss](pilot-safeguards.md#the-hidden-boss) |
| Members' share of all work hours, counting agency and contractor hours | [The hidden boss](pilot-safeguards.md#the-hidden-boss) |
Most of this is data a co-op already has in its payroll and bookkeeping. The expensive part is having an outside accountant check it.
## What outside checking costs
Outside accountants offer three levels of checking. A **compilation** just puts the books into proper form, with no checking at all. A **review** asks questions and looks for numbers that don't make sense, without testing the records underneath. An **audit** tests the records and gives a formal opinion. There's also a fourth kind of job, **agreed-upon procedures**, where the accountant runs a fixed list of tests and reports what they found, without giving an opinion on the accounts as a whole. It's billed on top of a review, not inside it.
For very small firms, the gap between a review and an audit is large. When the SEC wrote its rules for small companies raising money online, it estimated a review at about $1,500 to $18,000 and an audit at about $2,500 to $30,000. The average audit for companies with under $1 million in both market value and revenue was about $20,000 in 2014 ([SEC, 2015](https://www.federalregister.gov/documents/2015/11/16/2015-28220/crowdfunding)). By 2020, commenters and industry sources the SEC cited put a review of a small company at roughly $1,500 to $6,000, and one said at least $6,000 for a company with two years of history ([SEC, 2021](https://www.federalregister.gov/documents/2021/01/14/2020-24749/facilitating-capital-formation-and-expanding-investment-opportunities-by-improving-access-to-capital)).
Take a cleaning co-op with six members and $400,000 in sales. A yearly audit at $20,000 could be most of its profit. The model's own rough estimate for the package below is more like this, per year:
| Item | Rough cost |
| --- | --- |
| Review of the accounts | $3,000 to $6,000 |
| The fixed list of extra tests | $1,500 to $3,000 (the model's guess; no good public data) |
| Lock-holder's review, every two years | $500 to $1,000 a year (the model's guess) |
| Staff time | 20 to 40 hours |
That's roughly $5,000 to $10,000, or 1.25% to 2.5% of sales. In cleaning, where profit might be 5% of sales, that's a quarter to half of the profit. The package is cheaper than yearly audits, but it isn't cheap, and the page doesn't pretend otherwise.
## How others do it
**Italy** has checked every co-op for decades. The law requires a "cooperative revision" at least once every two years, or every year where a special law says so. If the co-op belongs to one of the national federations, the federation does it; if not, the government does. The revision has two jobs: to advise the board on running the co-op and on internal democracy, and to confirm the co-op is real. It checks that the members are real, that they take part, and that the co-op qualifies for its tax breaks, and it checks that worker-members are treated as the co-op's own rules say. A co-op that fails can be struck off the co-op register, put under a government-appointed manager, or dissolved ([Legislative Decree 220/2002, articles 2, 4 and 12](https://www.parlamento.it/parlam/leggi/deleghe/02220dl.htm)).
It's paid for by a fee on a published scale. For 2025–26, the smallest co-ops pay €330 for the two years, but only if they have at most 100 members, at most €5,160 in share capital *and* at most €75,000 in sales. Going over any one of those puts a co-op in a higher band. The top band, for co-ops with over €2 million in sales, is €2,740 ([Ministry decree of 12 February 2025, as reported by Fisco7](https://www.fisco7.it/2025/06/contributo-revisione-cooperative-2025-2026-in-vigore-i-nuovi-importi/)).
**Italy is also a warning.** Cheap checking by the co-ops' own federations has lived alongside a long-running problem of "false co-ops," firms that use the co-op form to underpay workers. In the first three months of 2016, labor inspectors who went looking for them inspected 934 co-ops and found more than half breaking the rules, with 3,768 workers affected ([Italian Ministry of Labour, 2016](https://www.lavoro.gov.it/stampa-e-media/comunicati/pagine/ministero-del-lavoro-vigilanza-sulle-cooperative-spurie)). Those were targeted inspections, not a random sample, so they don't show how common false co-ops are. They do show that a federation checking its own members isn't enough on its own.
**Mondragon's bank** asked every co-op for monthly financial reports, and gave each a full audit every four years ([Has This Been Tried?](precedents.md)).
## The rules
### Checking is scaled to size
Three sizes. Size is measured by sales and by full-time-equivalent workers, counting hours worked through agencies and contractors, so taking on contractors or part-timers doesn't keep a co-op small. Co-ops that are related, in the sense [the pilot already uses](pilot-safeguards.md#splitting-one-borrower-into-many) (a shared founder, manager, landlord, franchisor or major supplier), are counted together, so splitting a firm in two doesn't either. A co-op moves up a size when it passes either line two years running.
- **Small: under $2 million in sales and under 25 full-time equivalents.** A yearly review by an outside accountant, plus the fixed list of extra tests (below). No scheduled audit.
- **Middle: $2 million to $10 million, or 25 to 100 full-time equivalents, or any co-op owing the bank more than $750,000.** A yearly review with the extra tests, and a full audit every other year.
- **Large: over $10 million, or over 100 full-time equivalents.** A full audit every year.
Every co-op also sends the bank its quarterly payroll tax filings, which it already has, so the bank sees hours and pay four times a year at almost no cost.
### What a review can and can't stand in for
Wherever the model says "the co-op's auditor," it means whichever outside accountant does that year's review or audit. But a review isn't an audit, and a few rules need the real thing:
- **Capital account payouts** can go ahead on reviewed accounts. If a later audit finds profit was overstated, the extra credits are taken back out of members' unpaid credits, the same way [losses are shared](capital-accounts.md#how-its-paid-out).
- **Savings cover** starts only after the pool itself audits the co-op, at the pool's cost. After that, premiums can be priced on reviewed accounts, and the pool can audit any covered co-op at any time ([Protecting Members' Savings](savings-guarantee.md)).
- **Spreading the cost of a big project**, under [the long-run rule](the-long-run.md#the-rules), still needs an independent audit. A review doesn't count. A small co-op that wants to use the rule has to pay for one.
### One package, done once a year
- **The extra tests are the same everywhere.** The accountant runs a fixed list published by the bank and the federations before the first loan:
- every contract with an insider (a founder, manager, member's family, or a company they own), checked against what strangers would pay, the main way [a hidden boss](pilot-safeguards.md#the-hidden-boss) gets paid;
- members' share of all work hours, checked against payroll *and* against agency and contractor invoices;
- every worker's pay against the going rate for their occupation code, and, in co-ops under 25 full-time equivalents, every worker's code, with a sample in larger ones;
- a sample of timesheets against payroll;
- job titles where the Medicaid rule applies;
- the overtime recalculation.
- **One problem widens the check.** If a sample turns up anyone underpaid, the test is widened to every worker. Everyone underpaid, member or not, gets the difference with interest, and the co-op gets a full audit.
- **The list can't quietly grow.** Adding a test to the package requires publishing what it will cost co-ops, and the whole package's cost is reported every year.
- **The data comes from payroll.** Pay bands, the pay ratio, hires and departures, and members' statements come out of the standard payroll service the model already uses for overtime ([Owners Are Still Workers](labor-law.md#overtime-and-the-profit-share)). The co-op doesn't build them by hand.
- **One report.** Every yearly report on the list above goes on one standard form, filed once, with the lock-holder, the bank and the members. None of the model's own bodies asks a co-op for the same number twice. Government agencies like Medicaid or the IRS will still ask for their own.
- **Small groups are protected from the public, not from the members.** Where a published figure would reveal one person's pay or discipline, the public sees only the total. Members still see everything [the pay rule](pay.md#everyone-sees-the-bands) gives them, including managers' actual pay. The accountant and the lock-holder see the figures person by person, and a gap in pay or discipline between groups is a flag for the lock-holder's review.
- **The federation does the templates.** Standard bookkeeping, the report form, the occupation codes and the benchmark for the going rate are supplied centrally, so a small co-op isn't paying an accountant to invent them. Clean books also make the review cheaper.
### The accountant isn't the co-op's to pick freely
- **From an approved list.** The co-op chooses its accountant from a list the bank approves, and changes firms at least every seven years.
- **The lock-holder picks the samples**, so the co-op can't steer which timesheets or pay records get tested.
- **The report goes straight to the lock-holder and the bank**, as well as to the members.
- **Changing accountants is a warning sign.** If an accountant resigns or is replaced, the outgoing one writes to the bank saying why, and the co-op gets a full audit.
### Audits by lot, and when something looks wrong
Scaling checks down leaves more room to cheat in small co-ops. Three things close part of that gap:
- **One in twenty, by lot, after year-end.** Each year, after the books close, the pilot's outside reviewers draw one small co-op in twenty for a full audit, by a method published in advance, the same way they run [the support lottery](pilot-spec.md#the-support-lottery). At least half the draw is purely random. The rest is weighted toward co-ops with warning flags: heavy use of agency or contractor hours, a lot of insider contracts, or high turnover. The auditor is assigned, not chosen. What the audits find is published every year. Once a co-op has repaid the bank, the lock-holders run a narrower draw instead, of insider deals only ([Deals with insiders](the-lock.md#deals-with-insiders)).
- **Warning signs trigger an audit.** A small co-op gets a full audit if:
- its accounts are late, or its accountant resigns, is replaced or qualifies its report;
- the extra tests find a problem;
- it loses money two years running;
- equipment spending stays below wear long enough to call in [the inspector](the-long-run.md#running-it-down);
- payments to insiders grow faster than sales;
- its payroll tax filings don't match its accounts;
- members' share of work hours falls toward the minimum, or many hires are let go before they could join;
- it ends a year below Medicaid's 80% pay ratio, where [that rule applies](high-turnover.md#when-medicaid-decides-how-much-goes-to-pay);
- a labor or anti-discrimination agency makes a finding against it;
- it falls behind on the bank's loan;
- or before any sale of a large part of the business under [the lock](the-lock.md#closing-the-back-doors).
- **Anyone who works there can complain.** Members, non-members and anyone who left in the last two years can complain anonymously, in their own language, to the lock-holder or straight to the bank. They're protected by the same rule that protects members who report problems ([When a member won't do the work](free-riding.md#when-a-member-wont-do-the-work)). An upheld complaint leads to a targeted check of what it's about, not a full audit, so it can't be used just to run up a co-op's costs. Either side can appeal the lock-holder's decision to a different approved lock-holder.
The bank pays for audits by lot and for triggered audits, as part of its cost of lending, counted in its results like any lender's monitoring. When an audit finds fraud, the co-op pays it back.
### The lock-holder's review, every two years
- **Like Italy's revision, with more workers in it.** At least once every two years, the lock-holder checks that the members are real and do most of the work, that the membership rules and the insider-deal rules are followed, and that working conditions and discipline follow the co-op's own rules. It talks privately with a random group of workers, members and non-members, including people who left in the last two years, with an interpreter where needed. Its report goes to the members' meeting and the bank, not just the board.
- **Advice is separate and optional.** A co-op can buy advice from its lock-holder or federation, but the person who checks a co-op can't be its paid adviser in the same years.
- **A fee set from the real cost.** Lock-holders charge on a scale set by size, but the scale is approved by the bank from what a real review costs, not set by the lock-holders themselves. A fee too low to pay for a real review would just buy a weak one. Where the federation holds the lock, the fee is part of its dues and isn't charged twice.
- **Switching lock-holders is allowed, but not to escape a check.** A co-op can move to another approved lock-holder by a members' vote, but not while an audit, an unresolved finding or a vote the lock-holder can veto is open, and not in the 12 months before a review is due. The old lock-holder's file goes to the new one, and the review clock doesn't restart.
### What happens when a co-op fails a check
In steps, and each step can be appealed. A finding comes with a deadline to fix it. If it isn't fixed, payouts to board members and managers stop, new savings cover is suspended, and the bank can treat it as a breach of the loan. A co-op that refuses to be reviewed or audited is in breach of the loan and of the lock, and the lock-holder can go to court. None of this gives the lock-holder power to run the co-op.
### Who pays at the start
The bank's first loan can include the first three years of checking costs as working capital, so a new co-op doesn't have to find them in its first thin years. The pilot's business-help budget pays only for the shared pieces: the templates, the report form and the payroll service's setup.
### The cost is measured, and it can move the rules
Before the first loan, the bank and the federations publish what they expect the whole package to cost a co-op of each size. The pilot then records what it actually costs each co-op, as a share of its sales and of its profit, and publishes it by size and industry, along with what the audits by lot find.
The goal is under 1% of sales for small co-ops. The estimate above already misses it. If the typical small co-op spends more than 1% of sales for two years running, *and* the audits by lot are finding little, the lightest-value checks are cut back in an order published in advance: the group pay figures to every other year first, then the timesheet sample, then the review itself to every other year, with a compilation in between. If the audits are finding a lot, the checks stay, and the report says what honest checking costs in low-margin work.
## What this doesn't solve
- **Less checking means more room to cheat.** A reviewed small co-op is checked less deeply than an audited one. Audits by lot, warning signs and complaints make cheating risky, not impossible. Italy's false co-ops are the warning.
- **It costs real money in the work that can least afford it.** At 1% to 2.5% of sales, the package could take a large part of a cleaning or home care co-op's profit. The model has no way to make honest checking free, only cheaper.
- **The cost numbers are rough.** Two of the four lines in the estimate are guesses. There's no good US data on what small firms spend on outside accounting as a share of sales. The pilot's own numbers will be the first.
- **Checkers can be captured.** The co-op still pays its accountant, and lock-holders depend on the co-ops they check. The approved list, rotation, samples picked by the lock-holder, draws by outside reviewers and the limits on switching are meant to limit that. Italy has the same tension.
- **Fees don't cover staff time.** Filling in a form and answering an accountant's questions takes hours in a firm with no office staff. Payroll data and templates cut that down. They don't remove it.
@@ -0,0 +1,101 @@
---
title: What If Conservice Had Been a Co-op?
section: Making the Case
order: 160
summary: A real company that has taken on new private equity owners three times in ten years, imagined instead as a locked co-op from the day it was founded, with who would govern it, where its profit would go, and who would have received less.
---
# What If Conservice Had Been a Co-op?
[The thesis](thesis.md) uses one example: "TPG's stake in Conservice." This page works that example through.
It isn't a plan to take Conservice from anyone. This model doesn't buy or seize companies, and nothing here says any owner or founder did anything wrong. Every sale described below was lawful and chosen by the people selling. The question is about the structure, not the people: **what if a company with Conservice's business had been founded as a [locked co-op](the-lock.md) in 2000?** Who would govern it, where would its profit go, and who would have received less?
Everything on this page comes from public sources.
## The company as it is
Conservice, based in Logan, Utah, was founded on August 28, 2000, by David Jenkins and a business partner. It started out billing apartment residents for their utilities ([Herald Journal, 2020](https://www.hjnews.com/news/business/progressive-workplace-conservice-marks-20-years-as-standout-local-employer/article_5768eef4-b633-58a2-b036-7077c76cf86e.html)). Today it handles utilities for nearly 8 million rental units and other properties, working with more than 20,000 utility providers ([TPG, 2025](https://www.tpg.com/news-and-insights/tpg-to-acquire-majority-stake-in-conservice)).
It had 2,083 employees in 2020, up almost 900 in four years. By 2025 more than 2,000 came from its home county alone, making it one of the county's largest private employers. About 5,000 local people have worked there over its 25 years ([Herald Journal, 2025](https://www.hjnews.com/news/business/logan-based-company-conservice-celebrates-25-years/article_c24ef502-2cb3-4c43-916d-9a7320ec5977.html)). Its total headcount across all offices isn't public.
A local-paper headline called it a "standout local employer." It moved its staff home early in the pandemic when, as its founder said, "we could make people come in the office." It gives paid time for community service and matches part of workers' retirement savings ([Conservice careers page](https://www.conservice.com/careers)). Those benefits are real. They're also the owners' to give and the owners' to take back. **This page isn't about bad management.** It's about who holds the claim on everyone's work.
**Who owns it.** Private equity firms, which buy into companies with investors' money and borrowed money, have held stakes in it one after another:
- **2016:** TA Associates invests ([TA](https://www.ta.com/portfolio/investments/conservice/)).
- **2020:** Advent International buys a minority stake, at a value of $2.66 billion for the whole company ([Herald Journal, 2020](https://www.hjnews.com/news/business/progressive-workplace-conservice-marks-20-years-as-standout-local-employer/article_5768eef4-b633-58a2-b036-7077c76cf86e.html)).
- **December 2025:** TPG agrees to buy a majority stake. TA sells out completely, and Advent keeps a large stake. The price wasn't published ([TPG](https://www.tpg.com/news-and-insights/tpg-to-acquire-majority-stake-in-conservice)).
**What it earns.** TPG's purchase was backed by a loan package of more than $1 billion. The company was presented to lenders as earning about $230 million a year before interest, taxes and the accounting charges for wear and tear on its assets (a measure called EBITDA). People close to the deal told the trade press the real figure was more likely $175 million to $225 million ([Octus, January 2026](https://octus.com/resources/articles/update-1-blue-owl-crescent-expected-to-lead-private-credit-financing-for-conservice-buyout-by-tpg/)).
## Where the money goes now
- **Workers** get salaries and benefits. As far as public sources show, they have no vote on who owns the company, who runs it or where its profit goes.
- **Lenders** get the interest on more than $1 billion. That money backed TPG's purchase. In deals like this it usually also pays off the company's earlier loans. Either way, it's the company's own earnings that pay it back.
- **Owners** get what's left, and then the gain when they sell to the next owner. Each sale paid the previous owners, including the founders, at a price the buyer put at risk. And each price was a bet on the future work of people who still work there and had no say in the sale.
That's what the thesis means by a claim on other people's labor that the owner doesn't work alongside. A stake like that can be sold, again and again, and each sale puts a price on thousands of people's future work.
## The same company, founded as a co-op
Now imagine the same business, the same customers and the same growth, but founded in 2000 as a worker co-op under this model. That's a big assumption, discussed below.
**Who governs.** The workers are members, each with one vote. They elect a board, and the board hires the chief executive. Managers still manage. Members don't vote on every decision, only on what their bylaws say they vote on ([Worker Ownership](worker-ownership.md)). The company could have leaders much like its current ones. The difference is who they answer to.
**Not everyone is equal on day one.** New workers become members after a probation of up to a year. Their share of the profit then grows over about five years until it's full. Non-members can do up to a quarter of the work hours, but no more ([Guarding the Pilot](pilot-safeguards.md#the-hidden-boss)).
**No sales.** Under [the lock](the-lock.md), the company can never be sold to conventional owners. The 2016, 2020 and 2025 deals don't happen. No buyer means no billion-dollar loan to pay back.
**Where the profit goes.** Under the [default split](capital-accounts.md#where-each-years-profit-goes), 30% goes to a permanent reserve that nobody can ever withdraw, which pays for growth, the job outside investors' money does today. Up to 10% goes to the [federation](federation.md)'s shared fund. About 60% goes to members, split by how long each has worked there, up to a cap. At least a fifth of each member's share is paid in cash, half by default. The rest goes into their [capital account](capital-accounts.md), which is paid out after they leave. So nearly everyone among the 5,000 people who have worked there, everyone who stayed past probation, would have left with something.
**Unions still have a place.** Members can still organize, and a union can still bargain over pay, schedules and grievances with the managers the board hires. Owning the company doesn't make every disagreement disappear.
## What it could mean in dollars
This model's case isn't about money ([Making the Case](the-argument.md)). But the numbers show the scale.
They need two assumptions, because the real figures aren't public:
- **Profit.** EBITDA is higher than real profit, because it leaves out the cost of equipment and software that wears out. So assume real profit of **$125 million to $175 million** a year.
- **Headcount.** More than 2,000 employees come from the home county, and there are other offices. So assume **2,500 to 4,000** workers.
The table also assumes the average member holds about 70% of a full share, since newer members are still ramping up.
| Real profit | Workers | **A full-share member's profit share each year** (before income tax; about half in cash, the rest in their account) |
| --- | --- | --- |
| $125 million | 4,000 | about $27,000 |
| $125 million | 2,500 | about $43,000 |
| $175 million | 4,000 | about $38,000 |
| $175 million | 2,500 | about $60,000 |
In a co-op, salary and profit share are two parts of one income. Members set the split, within the model's rules. The figures above are on top of whatever salaries the co-op pays, which might not match today's. On taxes: a co-op generally doesn't pay corporate income tax on the profit it assigns to members, but members pay income tax on their whole share in the year it's assigned. The reserve is taxed like any company's profit.
These are rough numbers from public reporting, for a company that may never have existed in this form. They show the scale, not a promise.
## Would it have grown?
This is the honest weak point.
- **It grew for sixteen years before any private equity firm bought in.** By 2016 it already had roughly 1,200 employees (2,083 in 2020, less the almost 900 added in the four years before). So its early growth didn't depend on private equity.
- **Private equity's years brought acquisitions.** In 2020 it bought Capturis, a North Dakota company that pays commercial buildings' utility bills ([Conservice, 2020](https://www.conservice.com/press-releases/conservice-acquires-capturis-bringing-value-to-commercial-markets-served/)), and it has bought others since. A co-op can buy companies too. But under [the lock](the-lock.md), the people who work there have to become members, and some sellers wouldn't accept that. Otherwise the co-op ends up like Mondragon's foreign subsidiaries: a members' club that owns firms full of workers with no stake. So a co-op version would probably be smaller, and some of today's earnings came from companies it might not have bought.
- **Borrowing would be harder.** A first loan from [the bank](the-bank.md), at market rates with no personal guarantees, is exactly what the bank exists for. (The real company had two founders. The [pilot](pilot-spec.md#who-can-borrow) would have required a third.) But the pilot caps each co-op at $3 million in loans over its lifetime, so the bank gets a company like this started, not grown. After that, a profitable business can borrow from ordinary lenders. Conservice's earlier loan came from private lending funds at a normal rate for a company like it ([Octus](https://octus.com/resources/articles/update-1-blue-owl-crescent-expected-to-lead-private-credit-financing-for-conservice-buyout-by-tpg/)). But those lenders were lending to a company with owners' money beneath them to absorb losses first. A co-op, with its members' accounts paid out as people leave and [the lock](the-lock.md) narrowing who could buy it if it failed, would likely borrow less, at a higher price.
Whether co-ops can grow on those terms is exactly what [the test](the-test.md) is for.
## Who would have received less
**The founders.** As members, they would have had a founding share, their accounts, and executive pay while they ran it. They would never have sold. In the model's 200-person example, a co-op founder ends up with about $980,000 against $29 million to $38 million for a conventional owner ([The Founder Problem](founder-problem.md#the-numbers)). Conservice was valued at $2.66 billion in 2020, so at its scale the gap would be far wider. What the founders actually received hasn't been published.
The model's aim is for that value to stay with the people who keep producing it. But that raises the question the whole model rests on: **would these founders have started this company as a co-op?** Nobody knows. The founder problem isn't solved, and this example doesn't solve it.
**The investors.** TA, Advent and TPG could still have lent to the co-op, or put in money for a share of profits that ends after a set time ([Capital, After](capital.md)). That would still be a claim on the members' work. The model's answer isn't that such a claim is free. It's that the claim ends and carries no vote. What they couldn't have bought is a permanent claim, the kind that can be sold again.
**The workers, in one way.** A member's account rises and falls with the company. The [insurance pool](savings-guarantee.md) protects part of it, not all of it.
## What this example doesn't prove
- **It assumes the company would exist either way.** That's the biggest assumption on the page. If worker ownership makes firms less productive, or if the founders would never have started it, the comparison falls apart. [The pilot](pilot-spec.md) is built to find out.
- **Conservice is unusually profitable.** A home-care co-op has very little profit to share. For low-margin work, ownership brings a say and not much money, which is why the model also has [a floor](the-floor.md).
- **Ownership changes who gets the profit, not who pays it.** A co-op version would bill the same renters, who don't choose their building's billing company. They'd still need the same protection from unfair fees that they need now.
- **Dollars aren't the main point.** It's about who decides. Thousands of people in one valley have seen new owners buy into their company three times in ten years, and none of them had a vote on any of those deals. In the co-op version, they would.
@@ -0,0 +1,27 @@
---
title: The Constitution
section: Guardrails
order: 130
summary: The biggest risk is that the model gets repealed, not that the state overreaches. Plus money out of politics and rights the majority can't take away.
---
# The Constitution
This model asks little of the state: charter [a bank](the-bank.md), enforce [the lock](the-lock.md), run [the floor](the-floor.md). The concentration-of-power argument still applies to the floor (see [Open Problems](open-problems.md) on "essential" sectors), but it isn't the biggest risk.
**The main risk is durability, not overreach.** A model that can be built gradually can be dismantled gradually. A future Congress can defund the bank, and a statutory lock can be repealed by the same kind of majority that passed it. Postwar social democracy got rolled back once the coalition that built it weakened; a voluntary co-op sector is exposed to the same thing. So the design question is: **what makes this hard to reverse?** Partial answers: the lock written into each co-op's own charter and backed by an independent lock share (see [The Lock](the-lock.md#how-the-lock-is-made-to-hold)), and into state co-op law, not just federal statute; a federation that becomes financially independent of the public bank; and, above all, a co-op sector large enough to have its own constituency. How each of those works, what it can't protect, and what happens now that any president can replace the bank's board, is in [Making It Last](durability.md).
**Money out of politics.** Legislative work pays well enough that good people want the job, but any money, gift, or benefit from a non-government entity ends the salary and starts a prison sentence. Politicians should benefit only from serving their people.
**But representation alone is not a guardrail.** This is the trapdoor, and it needs saying explicitly:
> *A government that perfectly represents its people will target a group whenever the people want that group targeted.*
Representation amplifies the majority; it does not restrain it. Jim Crow was representative: it represented its white voters faithfully, and that is exactly how it ground people down for a century. Internment was popular. Clean elections stop capture *by the rich*. They do nothing to stop the majority deciding a minority is the enemy.
**So: counter-majoritarian rights.** Things a person keeps *even when everyone around them votes to strip them away.* The whole point of a right is that it holds on the day the majority is wrong, and you cannot assume in advance that you'll always be the majority, or always be right.
- Keep the Bill of Rights. Make rights that protect individuals **extremely difficult to repeal.**
- **Two tiers.** Speech, due process, equal protection, freedom from imprisonment without trial: near-absolute. Property: strong, but balanceable against the public interest with compensation, which is what the Takings Clause already says. *(This model doesn't touch existing property at all. It asks nothing of the property tier.)*
- **The Senate had the right idea, badly built.** Keep the cooling-saucer function: a second chamber that slows things down and forces broad coalitions instead of bare-51% steamrolls. Drop the geographic weighting. Land should not get votes. Equal state representation and the protection of slavery were the *same bargain* in 1787, and that structure later shielded Jim Crow.
- **No final guardrail exists.** Entrench rights and you've moved power to whoever *interprets* them. There's no rule so perfect it guards itself. The answer isn't a better final rule; it's **redundancy**: several overlapping checks, none supreme, so that capturing any one of them doesn't hand someone the whole apparatus.
@@ -0,0 +1,92 @@
---
title: When Work Runs Short
section: The Co-op
order: 23
summary: When a co-op's business falls off, the model has everyone share the cut before anyone loses their job, with profit shares first, then fewer hours across the board, and layoffs only last, by a vote and in an order written down in advance that treats members and non-members alike.
---
# When Work Runs Short
Every business has bad years. A conventional company usually answers with layoffs: the owners decide, and the people let go have no say. A co-op's members decide for themselves, and that cuts both ways. They can choose to share the pain. Or a majority can vote to keep its own pay and let the newest people go.
The founding plan says the federation's shared fund helps a co-op through a bad year ([The Federation](federation.md)). It doesn't say what the co-op itself does first. This page sets that order.
## What co-ops actually do
**They cut pay before jobs.** A study of Italian firms from 1982 to 1994, using records that match workers to their employers, found worker co-ops paid 14% less on average than conventional firms, and that their pay went up and down more while their employment went up and down less ([Pencavel, Pistaferri and Schivardi, 2006](https://econpapers.repec.org/RePEc:sae:ilrrev:v:60:y:2006:i:1:p:23-44)). A study of every co-op in Uruguay's social security records from 1996 to 2005 looked at the same question ([Burdín and Dean, 2009](https://ideas.repec.org/a/eee/jcecon/v37y2009i4p517-533.html)). Later researchers summarize it as finding the same pattern. I couldn't open the full paper to check.
**Mondragon moved people instead of firing them.** When Fagor, its appliance maker, went bankrupt in 2013, other Mondragon co-ops had taken on 417 of its members within two months, and around 300 more could retire early ([CECOP, January 2014](https://www.cecop.coop/works/the-mondragon-corporation-has-already-relocated-417-worker-members-from-fagor-electrodomesticos)). Two sympathetic writers reported at the time that Mondragon's own insurer, Lagun Aro, would pay members 80% of their pay for two years while it looked for places for them. They also noted that Fagor had about 2,000 workers in the Basque Country and about 3,500 more in factories in France, China, Poland and Morocco who weren't members, and that what would happen to those workers was "unclear" ([Hanna and Alperovitz, 2013](https://geo.coop/story/mondragon-and-system-problem)). Protecting members can mean pushing the cut onto everyone else.
**America already has a tool for sharing the cut.** Under work sharing, an employer cuts everyone's hours instead of laying some people off, and the state's unemployment insurance pays part of the lost wages. If your week goes from 40 hours to 32, you get 20% of the weekly benefit you'd have received if you'd been laid off. The Department of Labor lists 33 states with it in law, 30 of them running it ([fact sheet](https://oui.doleta.gov/unemploy/docs/factsheet/STC_FactSheet.pdf)). Under federal law it pays only for cuts in hours, not in the rate of pay, the cut has to be between 10% and 60% of the week, the state has to approve the employer's written plan first, and any health and retirement benefits the employer provides have to continue on the same terms as for everyone else ([26 U.S.C. § 3306(v)](https://www.law.cornell.edu/uscode/text/26/3306)).
**What the evidence can't settle.** The studies are averages over many co-ops. They don't say whether pay cuts drove away the people with the best outside offers, or whether jobs kept in bad years were worth the lower pay in good ones. The 14% gap may partly be the price of that security.
## The order
Every locked co-op's charter sets this order. A co-op can go further, but it can't skip a step.
1. **Profit shares fall first.** That happens on its own: when profit falls, so do the shares, and so do team bonuses that depend on results ([Does Sharing the Profit Mean Less Effort?](free-riding.md#team-bonuses-the-members-design)).
2. **Then everyone shares the cut.** Before any layoff, the co-op cuts across the board.
- **Pay above the going rate goes first.** Every worker has to be paid at least the going rate for their job for the hours they work ([How a Locked Co-op Is Taxed](taxes.md#wages-first)). So a cut in the *rate* of pay can only come out of pay above the going rate. Everything beyond that is a cut in hours.
- **Salaried staff share the cut the same way, set in advance.** Staff paid a fixed salary with no overtime, usually managers and professionals, have to get their full salary for any week in which they do any work ([29 CFR 541.602](https://www.law.cornell.edu/cfr/text/29/541.602)). A slowdown can still lower it, if the lower salary is set in advance for the length of the cut and isn't adjusted week to week ([Labor Department Fact Sheet #70](https://www.dol.gov/agencies/whd/fact-sheets/70-flsa-furloughs)). So:
- **Less pay means less work.** Their salary falls by the same percentage as everyone else's hours, with a written shorter schedule to match. Or they take whole weeks off unpaid, which the law allows since no pay is owed for a week with no work. Their hours are recorded for as long as the cut lasts. Work beyond the shorter schedule needs a manager's approval in advance and is paid on top of the salary at the old rate, which the law also allows ([29 CFR 541.604](https://www.law.cornell.edu/cfr/text/29/541.604)).
- **Their cut keeps pace.** If hours cuts deepen, theirs follows from the next pay period. Otherwise their salary changes only when the cut starts and ends.
- **Below the floor, they're hourly.** If the cut takes someone's salary below the federal floor of $684 a week ([29 CFR 541.600](https://www.law.cornell.edu/cfr/text/29/541.600)), or below a higher state floor, they're treated like hourly workers for as long as the cut lasts, with overtime and every other rule that goes with it. California's floor, for example, is twice the state minimum wage for full-time work ([Cal. Labor Code § 515](https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=LAB&sectionNum=515)), which comes to $70,304 a year in 2026, and hourly workers there also get daily overtime and meal and rest breaks.
- **Hours cuts go through work sharing** where the state has it, so workers get part of their lost pay back from unemployment insurance, where the state covers them ([Owners Are Still Workers](labor-law.md#unemployment-insurance-covered-in-most-states-not-all)).
- **The bottom never takes the bigger cut.** Nobody's weekly earnings can fall by a larger percentage than those of anyone paid more.
- **Benefits stay.** Health cover and retirement contributions continue as before. That's the model's own rule, and it's also what work sharing requires.
- **Only where the work fell off.** Where a co-op has separate lines of business that report their own results ([Investing for the Long Run](the-long-run.md#when-members-stop-investing-or-invest-badly)), the cut can be limited to the line that lost the work. Closing a whole line still needs the vote below.
3. **Then the voluntary routes.** Before laying anyone off, the co-op offers unpaid leave with the right to come back, shorter hours for those who want them, and moves to other locked co-ops through the [federation](federation.md).
4. **Only then layoffs,** in the order the bylaws set in advance (below).
**The co-op can run at a loss to keep people.** Losses are shared between the reserve and members' [capital accounts](capital-accounts.md), like profits. But pay above the going rate in a loss year is charged back to the people who got it ([Investing for the Long Run](the-long-run.md#the-rules), rule 5), so a loss can't protect high pay at the reserve's expense.
## Who decides
- **The board can act fast, once.** It can cut everyone's hours by 10% to 20% for up to 60 days without a vote, so the co-op isn't stuck waiting for a meeting while cash runs out. Salaried staff, the board's own members included, take the same percentage in the way set out above. The board can't set a different cut for itself. It can do that once in any 12 months. Anything after that needs the members. A board cut still counts in the test against [pushing people on probation out](high-turnover.md#no-pushing-people-out-before-theyd-join), whatever its size.
- **Anything more goes to the members.** Cutting pay, cutting hours for longer or by more, and laying off anyone on the payroll, member or not, all need a vote of the members, by secret ballot, with at least 7 days' notice.
- **A layoff always has an alternative on the ballot.** A plan that lays anyone off has to be voted on next to one that saves the same amount by cutting across everyone. The board costs both. The co-op's outside accountant checks the two costings, and the assumptions go out with the ballot.
- **Choosing layoffs needs two majorities.** The layoff option passes only with a majority of all members and a majority of the lower-paid half, the same double vote [Who Sets the Pay](pay.md#two-majorities-not-one) uses. The lower-paid are the most likely to be let go, so they get a separate say. People on probation aren't members yet, so they don't vote, even though they're usually the first to go. Laying them off counts in the test against pushing people out before they'd join.
- **If nothing passes, the shared cut happens.** If the board's 60 days run out with no option approved, the costed across-the-board cut takes effect, for salaried staff too. Doing nothing isn't an option when the money is running out.
## The layoff order
Every co-op's bylaws must include a written layoff order before it has anyone to lay off. The standard bylaws use seniority within each kind of job, among people who can do the work that's left. A co-op can choose other criteria by vote. The charter sets the limits.
- **Written ahead of time.** A change to the layoff order, or to how jobs are grouped for it, takes effect 12 months after it's adopted. So nobody can rewrite it, or regroup people's jobs, during a downturn to reach particular people.
- **Members and non-members alike.** The same criteria apply to everyone on the payroll, and to the workers of any company the co-op controls while it's being merged in or converted ([The Lock](the-lock.md#when-a-co-op-owns-another-company)). Time counts toward seniority whether it was spent as a member, on probation or as someone who chose not to join, and so does time on medical, family or military leave. A co-op can't keep a layer of non-members to lay off first, outside the protections members get, the way Fagor's workers abroad were.
- **Accommodation counts.** Whether someone "can do the work that's left" is judged with any reasonable change for a disability, as the law already requires.
- **Agency and contract work ends first.** Where agency workers or contractors do the same work as people on the payroll, those contracts end before anyone on the payroll is laid off. Their hours already count in [the lock's](the-lock.md) test of who does the work, and a co-op can't use them to dodge it.
- **The dismissal protections apply.** A layoff that doesn't follow the order can go to the same hearing and outside arbitrator as a dismissal ([Does Sharing the Profit Mean Less Effort?](free-riding.md#when-a-member-wont-do-the-work)). The rules on retaliation, disability and leave apply too. If someone laid off had organized, filed a complaint or led opposition in a vote within the last 12 months, the co-op has to show it followed the order.
- **Notice.** At least 30 days' notice, or pay instead, or 14 days in a co-op of fewer than 15 workers. The law often requires more. Under federal law, employers with 100 or more full-time workers give 60 days' notice of large layoffs and closings, and cutting someone's hours by more than half for six months counts as a layoff ([29 U.S.C. § 2101](https://www.law.cornell.edu/uscode/text/29/2101), [§ 2102](https://www.law.cornell.edu/uscode/text/29/2102)). A long, deep work-sharing cut can trigger it. Some states require 90 days, including New York for employers with 50 or more full-time workers there ([Cooley, 2023](https://www.cooley.com/news/insight/2023/2023-08-03-new-york-state-amends-its-warn-act-regulations)) and New Jersey, which also requires severance ([Jones Day, 2023](https://www.jonesday.com/en/insights/2023/04/new-jersey-warn-act-amendments)).
- **Severance** follows the bylaws, and never less than state law requires. When a co-op closes, notice pay and severance from different laws don't add up; the worker gets the largest of each ([Protecting Members' Savings](savings-guarantee.md#notice-pay-and-severance)). The standard bylaws pay one week per year worked, the same as New Jersey requires of larger employers in mass layoffs and closings. More than two weeks per year, or more than 26 weeks in all, counts as pay under [the pay rules](the-long-run.md#the-rules).
- **Lopsided layoffs get checked.** Federal agencies treat a selection rate for any race, sex or ethnic group below four-fifths of the rate for the group doing best as a sign of possible discrimination ([29 C.F.R. § 1607.4(D)](https://www.law.cornell.edu/cfr/text/29/1607.4)). The model uses the same line for layoffs, and adds age. If the share of any gender, race or age group keeping their jobs falls below it, the co-op publishes a written explanation before the layoffs take effect, and anyone laid off can take it to the outside arbitrator. The check looks at the criteria, not at who they picked. A co-op can drop a criterion that isn't needed for the work, for everyone, but it can't change who's laid off because of their race, sex or age, which employment law forbids too ([*Ricci v. DeStefano*, 2009](https://www.law.cornell.edu/supremecourt/text/07-1428)).
## Coming back
- **A right to be recalled for 12 months,** in reverse order of the layoff, to the same job at the same hours or more. Until everyone laid off from a job has been offered it back, the co-op can't hire anyone new into that job, under any title, or hand the work to a contractor. Someone who doesn't accept within 14 days drops to the back of the list.
- **Membership comes back with them.** A member who returns within 12 months doesn't start probation again, and goes back to the same step on the [profit share ramp](worker-ownership.md).
- **So do their savings.** A laid-off member's capital account is fixed and paid on the normal schedule, like anyone who leaves ([Member Capital Accounts](capital-accounts.md)). If they come back, whatever hasn't been paid out yet becomes their account again and counts toward the buy-in.
## Unemployment insurance
People laid off from a co-op claim unemployment insurance like anyone else, where their state covers co-op members ([Owners Are Still Workers](labor-law.md#unemployment-insurance-covered-in-most-states-not-all)). Members can't lay themselves off in turns to collect it, except through the state's work-sharing program. That's the abuse some states cite for leaving co-op owners out, and the model gives it up to keep members covered. The outside accountant checks claims against the schedule. And like any employer, a co-op whose workers claim benefits pays a higher unemployment tax rate afterward.
## When cutting isn't enough
Sharing the cut buys time. It doesn't save a business nobody wants to buy from. Help from the federation's fund comes with a recovery plan and a limit agreed before any crisis ([Investing for the Long Run](the-long-run.md#when-members-stop-investing-or-invest-badly)). Help for the workers, like finding them places in other co-ops, has no such limit. **No working on credit.** Once a co-op misses a payroll by more than five business days, it can't schedule more ordinary work until everyone is paid up, unless it files for bankruptcy protection or the federation's fund lends it the payroll. Safety, care and wind-down work goes on, paid by the pool. Federal law, and some states, can make officers and even members pay wages the co-op doesn't ([Owners Are Still Workers](labor-law.md#when-wages-go-unpaid-members-can-owe-them)), so this protects them as well as the people owed.
If the co-op fails, [the insurance pool](savings-guarantee.md) covers workers' last paychecks and part of their savings, and [When a Co-op Goes Bankrupt](bankruptcy.md) covers what happens to the business.
## Measuring it
The pilot publishes, for each co-op's bad years compared with matched conventional firms: changes in hours, pay and headcount; layoffs by member status, gender, race and age; how many people were recalled; and how many laid-off members found work in other co-ops. If co-ops turn out to protect members by letting non-members go, that shows up in the figures.
## What this doesn't solve
- **Cuts drive some people away.** The people with the best outside offers leave first. Cuts to pay above the going rate fall on the better-paid, and in a loss year [rule 5](the-long-run.md#the-rules) charges them again, out of their accounts. A co-op can keep every job and still lose the skills it needs most.
- **Sharing the cut can keep a dying business going.** Everyone taking less for years can put off a closing that should have happened, while the reserve and members' accounts run down.
- **Work sharing isn't everywhere.** In the states without it, sharing the cut means lost pay with no insurance to make up part of it, and layoffs look better by comparison.
- **The shock still lands on someone.** Ending agency and contract work first protects the co-op's own workers by pushing the cut onto people outside it. That's what every employer does, and it's close to what happened to Fagor's workers abroad.
- **Hiring gets more cautious.** Recall rights, notice and the vote all make adding people in good years a bigger commitment. Co-ops will likely hire more slowly, which the evidence already suggests they do.
- **Members can still choose layoffs.** Two majorities make it harder, not impossible. The newest members are the easiest to vote out of a job.
@@ -0,0 +1,227 @@
---
title: Making It Last
section: Guardrails
order: 125
summary: Anything built by one law can be undone by the next, so the model is built to survive a hostile government, and this page says what that protects and what it can't.
---
# Making It Last
The biggest threat to this model isn't that the state grows too strong. It's that a later government undoes it. Everything here rests on ordinary laws: a charter for [the bank](the-bank.md), a law backing [the lock](the-lock.md), and money set aside for [the pilot](pilot-spec.md). Anything one Congress passes, a later one can repeal ([The Constitution](constitution.md)).
Repeal is only the most obvious way to kill it. Public programs die in quieter ways too. They get starved of money and staff, run by people who want them to fail, or left without anyone able to sign off on decisions. A hostile government can also make an experiment *look* like it failed. This page goes through how those things have happened to real programs, and what the model does about each one. Some of it can be protected well. Some of it can't be protected at all, and the page says which is which.
## How public programs get killed
**By doing nothing: the Export-Import Bank.** America's export-credit bank has to be reauthorized by Congress every few years. In 2015 Congress let its charter lapse, and from July 1 until early December it couldn't make new loans. Then a second problem hit. Its board needs three voting members to act, and when two terms expired in July 2015, the Senate didn't confirm replacements. For nearly four years the bank couldn't approve any deal over $10 million. By mid-2016, more than 30 deals worth over $20 billion were stuck waiting ([Congressional Research Service, 2016](https://www.everycrsreport.com/reports/IN10574.html); [2019](https://www.everycrsreport.com/files/20190809_R43581_3f42ff567900d03421b053fdee99f7450d886877.html)). Nobody had to vote to shut it down. Opponents only had to not act.
**By cutting its staff: the CDFI Fund.** The Treasury's Community Development Financial Institutions Fund has backed community lenders for three decades. In March 2025, a presidential order told it to shrink to "the minimum presence and function required by law." In October 2025 the Treasury moved to lay off its roughly 100 staff. 105 Republican members of Congress wrote to defend it, and a court order blocking layoffs during that fall's government shutdown held them off for the time being ([Roll Call, 2025](https://rollcall.com/2025/10/27/senate-and-house-gop-defend-community-finance-fund-from-trump-cuts/)). A program that runs on the government's staff can be starved without being repealed.
**By taking the money back: the Greenhouse Gas Reduction Fund.** Congress created this $27 billion fund in 2022 to seed green lenders. By early 2025, $20 billion had been awarded to eight nonprofit lenders and was held at Citibank, which acted as the government's financial agent. In March 2025 the new administration terminated the grants and froze the money. The courts have gone back and forth, and the case is still going ([Columbia Law School Climate Law Blog, 2026](https://blogs.law.columbia.edu/climatechange/2026/08/07/a-big-win-in-few-words-what-to-glean-from-the-climate-united-fund-decision/)). Then, in July 2025, Congress repealed the program and took back all of its money that hadn't yet been committed. It did that inside a huge budget bill, passed by simple majorities under special rules that block a Senate filibuster. Because almost all of the money had already been awarded, the repeal itself took back only about $19 million ([Congressional Record, July 9, 2025](https://www.congress.gov/119/crec/2025/07/09/171/118/modified/CREC-2025-07-09-pt1-PgS4281-4.htm)). Two lessons: money the executive branch can reach can be frozen with a letter, and money not yet committed can be taken back by a line in a bill few people read. Money already awarded was much harder to take back.
**By changing what it is: America's co-op bank.** When the Reagan administration tried to close the National Consumer Cooperative Bank in 1981, it survived by being privatized. Then it drifted toward lending to co-ops that could have borrowed anywhere ([Has This Been Tried?](precedents.md#americas-co-op-bank-the-national-consumer-cooperative-bank)).
## How public programs survive
**A fair trial and a steady base: the Bank of North Dakota.** North Dakota's state-owned bank opened in 1919, created by a farmers' movement openly sympathetic to socialism. Its opponents fought it every way they could. They sued and lost at the US Supreme Court in 1920. They recalled the governor in 1921, the first governor recall in American history. They put abolishing the bank on the ballot, and lost by 4,238 votes. They worked with East Coast financiers to boycott the state's bonds, and that boycott, along with the recall, helped push the movement that founded the bank out of power. What saved the bank itself, historians say, was the governor from the opposing side who replaced the recalled one. He decided it was "right and just and wise to give it a full, fair, and honest trial," and ran it "in a sane and conservative way" ([Bank of North Dakota history](https://thebndstory.nd.gov/years-of-desperation/organized-opposition-tried-to-take-the-bank-down/)). It also has a structural advantage: by state law, the state's own money must be deposited with it ([N.D. Century Code § 6-09-07](https://www.ndlegis.gov/cencode/t06c09.pdf)). It's still running more than a century later. It survived by being careful, which this model's bank is designed to be from the start.
**Owners spread everywhere: rural electric co-ops.** In the mid-1930s, nine in ten rural American homes had no electricity. Private utilities wouldn't wire sparse areas. From 1936, the federal Rural Electrification Administration lent money to farmers who formed co-ops to do it themselves. By 1953, over 90% of farms had power. In 1942, facing false accusations during the war, the co-ops formed a national association to speak for them in Washington. Today more than 900 electric co-ops serve over 40 million people ([America's Electric Cooperatives](https://www.electric.coop/our-organization/history); [2026](https://www.electric.coop/rea-at-90-years-celebrating-the-landmark-law-that-birthed-rural-electrification)). The program built its own defenders: owners in almost every congressional district. That cuts both ways. Critics can fairly say that defenders like these keep subsidized loans flowing long after the original job is done.
**Paying the government back: the Farm Credit System.** Congress started a system of farmer-owned co-op lenders in 1916 and expanded it in 1933, with federal money. The lenders repaid all of it, and by 1968 they were wholly owned by the farmers who borrowed from them ([Farm Credit Administration](https://www.fca.gov/about/historical-highlights-of-fca-and-the-fcs)). Once the system belongs to its borrowers, no budget vote can starve it and no president can replace its lenders' boards. It wasn't the end of the risk: in the 1980s farm crisis the system needed a federal rescue, of which about $1.3 billion was used.
**What political scientists find.** Eric Patashnik studied what happens to major reforms after they pass. He found that "the reforms that stick destroy an existing policy subsystem and reconfigure the political dynamic," creating their own supporters ([Reforms at Risk, Princeton University Press](https://press.princeton.edu/books/paperback/9780691138978/reforms-at-risk)). Reforms that leave the old interests in place tend to unravel.
## Who controls the bank now
The model says the bank should be walled off from politicians "the way monetary policy is walled off from Congress." The obvious way to do that is to give the bank's board fixed terms, so a president can remove them only for misconduct. That route is now closed.
In June 2026, in *Trump v. Slaughter*, the Supreme Court held that the Federal Trade Commission's protection from removal was unconstitutional, because officials under the President's "general administrative control" must be removable at will. It put aside only the Federal Reserve, "to the extent that it follows in the distinct historical tradition of the First and Second Banks of the United States—both of which influenced monetary policy" ([Trump v. Slaughter, 2026](https://www.supremecourt.gov/opinions/25pdf/25-332_qn12.pdf)). The same day, in *Trump v. Cook*, the Court upheld the Fed governors' protection, on the ground that "monetary policy should not be subject to political interference" ([Trump v. Cook, 2026](https://www.supremecourt.gov/opinions/25pdf/25a312_5468.pdf)). A bank that lends to co-ops doesn't set monetary policy, so it almost certainly doesn't fit that exception.
So this model assumes **any president can replace the bank's whole board, any time.** The protection has to come from what the board *can't* do, not from who sits on it. That's also more honest. A bank run by people nobody can remove would be exactly the unaccountable power this model warns against elsewhere.
## The rules
### What a new board can't change
The things that decide whether the experiment is fair are written into the law that creates the bank, not left to the board:
- the [co-op definition and the lock](worker-ownership.md) that borrowers must accept;
- the measures, thresholds, reviewers and deadlines in [the decision rule](pilot-spec.md#the-decision-rule);
- **the lending standards**, meaning the scoring model used to judge applications, filed with the rest of the plan before the first loan;
- the [rate audit](pilot-spec.md#what-market-rate-means), which works in both directions;
- the loan limits and the rules against [political favoritism](pilot-safeguards.md#political-favoritism);
- **how loans are managed once made.** A loan can be called in early only if payments stop, never for a technicality. The rules for handling a co-op in trouble are filed in advance too.
The board can propose changes to the lending standards, with published reasons. The reviewers then report every result both ways: under the original standards and under the new ones. By law, nobody outside the bank can direct a decision on a particular loan, except the President through the board he appoints. Every instruction or contact the bank receives about a particular loan is logged and published. The board itself can overrule its staff on a loan, because the Constitution probably requires that, but only in writing and in public ([Who has the last word](#who-has-the-last-word)).
### Rigging the result
A hostile board has two ways to make co-ops look like failures without breaking any rule openly. The reviewers check for both.
- **Turning away good applicants.** Every year, the reviewers re-score the bank's rejected applications against the filed standards and publish the rejection rate for applications that met them. Where the line falls is set in advance ([below](#when-rejections-mean-not-tested)). If too few co-ops get funded because applicants didn't show up, that's a real result, as [the pilot](pilot-spec.md#why-about-400-co-ops) says. If too few get funded because the bank turned away applicants who met its own standards, the final verdict says so: **"not tested,"** not "failed." The audit also covers co-ops in lower-paid work separately, so the bank can't quietly drift toward the safest co-ops ([Who the Bank Lends To](who-gets-loans.md)).
- **Making bad loans on purpose.** If the [emergency stop](pilot-spec.md#the-decision-rule) fires, the reviewers check whether the losses come mainly from loans that broke the filed standards. If they do, it's treated like fraud, not like a verdict on co-ops. Lending restarts under the original standards, and those loans are reported separately.
**This isn't a way to keep the bank alive.** "Not tested" means exactly what "failed" means for the bank: no new loans, the wind-down, and no second round of money. It only changes what the record says about co-ops. Nothing on this page extends the timeline either. The latest the final review can happen is year 15, which is the pilot's existing rule for when good applications are scarce.
### When rejections mean "not tested"
"Turned away applicants who met its own standards" needs a line drawn in advance, or a board could argue about it forever. The line is filed with the rest of the plan before the first loan.
**What the reviewers check.** Every year they re-score every complete application the bank rejected, and every one it approved. They also re-score a random sample of applications that were withdrawn, left incomplete or passed the 120-day limit, since pushing people to give up is another way of saying no. Checking every file, not a sample, removes the argument about sampling luck. It costs something, perhaps a few hundred thousand dollars a year if the bank decides several hundred applications, and that cost counts against the bank's money test like any other.
They score each file with the filed scoring model, from what the applicant submitted plus the checks the standards call for, like credit reports. Any change a loan officer made to the applicant's numbers counts only if it's a reason on the filed list. Paperwork made after the decision, like closing documents, is removed first, so the reviewers can't tell which files were approved.
Around the bank's approval line there's a band where the bank decides by lottery ([the lottery at the margin](pilot-spec.md#who-co-ops-are-compared-against)), and honest judgment calls fall there. A rejection counts as **wrongful** if both of these are true:
- the reviewers' score is above the top of that band;
- the file doesn't show a reason from the filed list of disqualifiers that the reviewers find is actually backed by the file. Examples are a false statement, a sham co-op, or a missing document. A missing document counts only if it's on a standard checklist every applicant gets, with a deadline filed in advance. A reason that isn't on the list doesn't count, however sensible it sounds. The bank can add reasons to the list, but only publicly and only for later decisions, the same way it changes any [lending standard](#what-a-new-board-cant-change).
Every rejection notice names the specific reason, which federal fair-lending law already requires for most small-business applicants and makes available on request for larger ones ([12 CFR 1002.9](https://www.law.cornell.edu/cfr/text/12/1002.9)), and also gives the applicant their score.
**The line.** The bank fails a year's audit on either of two tests:
1. **Too many turned away.** More than 1 in 10 of the applications scoring above the band were rejected wrongfully.
2. **The best kept out.** Wrongly rejected applicants scored higher than the approved applicants who also scored above the band, by more than chance explains. That comparison leaves out the lottery winners from inside the band, who would always score lower and make an honest bank look guilty.
Co-ops in lower-paid work are held to the same line separately ([Who the Bank Lends To](who-gets-loans.md#money-set-aside)), and the yearly report also breaks the rejection figures down by the founders' race, ethnicity and sex. The report also publishes how often each disqualifier was used, so a sudden jump in "sham co-op" or "missing document" stands out.
**The same line for approvals.** Rejections aren't the only way to rig a test. Approvals scored below the band, outside the lottery, with no filed reason, are counted too, and more than 1 in 10 fails the audit the same way. Those loans are reported separately, like loans that [broke the standards](#rigging-the-result). This keeps the audit from pushing loan officers toward saying yes to everything.
**Why 1 in 10.** There's no good benchmark for how often a lender wrongly turns away a clear pass, so 1 in 10 is a judgment call, set loose on purpose. Small-business loans are far less standardized than mortgages, and the lottery band already absorbs the close calls. Even so, when a bank is breaking its own rules on more than 1 clear case in 10, it isn't running the test the law describes. For approvals there's loose context: when mortgage lenders re-check loans they've already approved, they find serious errors in about 1.5% of them ([ACES, 2025](https://www.send2press.com/wire/aces-q4-and-cy-2025-mortgage-qc-industry-trends-report-shows-critical-defect-rate-falls-to-annual-low/)). That's a much more routine kind of loan, which is part of why the line here is set far looser.
**Fixing it, not just labeling it.** Every applicant found wrongly rejected, in any year, is offered a new decision within 60 days. It's made on the reviewers' score, and at staff level only a disqualifier the reviewers confirm can block it. The board can still overrule it in writing, but that counts against the bank in the audit ([Who has the last word](#who-has-the-last-word)). That undoes most of the damage while the pilot is running. The fix isn't a lending target. It only brings in applicants who met standards the bank filed itself. Loans made this way count in full in both deciding measures. They're also reported separately, since a group that waited a year may not be the group it was.
**What "not tested" then takes.** At the final review, the verdict is "not tested" instead of "failed" only if all three of these hold:
1. the pilot failed its test;
2. the bank failed the audit in at least one year;
3. the wrongful rejections that were never fixed could have changed the result. Those are the ones where the group turned down the new offer, had broken up, never got one, or had the new decision overruled by the board. The reviewers add them back as if each had done as well as funded co-ops with similar scores. If that would turn the survival result into a pass, the verdict is "not tested." If not, it stays "failed."
Otherwise a fail stays a fail. A pass stands too, since rigging that didn't work doesn't change what the co-ops did. And "not tested" gives the bank no claim to the fast-track vote or to new money.
**It can't easily be used to dodge a fail.** A friendly board can't see a fail coming and use this rule to hide it. The verdict comes years after lending ends, every wrongful rejection is offered a new decision, and step 3 asks whether the missing co-ops would have changed the numbers, not just whether some were missing. Rejecting good applicants on purpose to set up that excuse would also have to happen in writing, because an unrecorded reason counts against the bank. Any instruction to reject a particular application is covered by the rules against [political favoritism](pilot-safeguards.md#political-favoritism).
**What it can't catch.**
- **Scoring can be wrong.** If the filed scoring model itself counts against some founders more than it predicts repayment, re-scoring with the same model won't show it. That's checked separately ([Who the Bank Lends To](who-gets-loans.md#treated-the-same)).
- **People who never apply.** An audit of files can't see people who never applied because the bank was slow, hard to reach or cut its outreach. The published application and approval rates ([After a Pass](scale.md)) are the only check there.
- **What isn't written down.** Scoring from the file can't see what a loan officer learned in a meeting and didn't write down. That cuts both ways: a bank that wants its reasons counted has to write them down.
### No quorum, no freeze
Unlike the Export-Import Bank, this bank can't be frozen by leaving board seats empty. It uses ordinary delegation, which keeps the President in charge:
- **Standing authority for smaller loans.** The board appoints senior credit officers and gives them authority to approve loans up to $1 million under the filed standards. That authority doesn't lapse when board seats go empty. Any board can take it back, publicly, but it can't disappear by default.
- **No quorum means an acting chair, not no one.** If the board can't meet, the Treasury Secretary acts for it, but only on loans too big for staff and on reviewing staff decisions. The bank's own law has to say so. The general federal law on acting officials doesn't cover seats on the board of a government corporation ([5 U.S.C. § 3349c](https://www.law.cornell.edu/uscode/text/5/3349c)). Every rule on the board's own overrules ([below](#who-has-the-last-word)) applies to the Secretary too, and every decision made without a quorum is flagged in the results. The Secretary can't change the lending standards, and can take away a credit officer's authority only for cause, in writing.
- **Emptying the board concentrates power. It doesn't hide it.** A president who fires the board and names no one puts its review power in one cabinet officer's hands. That's better than a freeze, and every use of it is public, but it's a single point of control. The results report how long each vacancy lasted and whether the quorum was lost through firings or through terms running out.
- **Applications can't be buried.** A complete application gets a decision within 120 days, yes or no, with reasons. The clock pauses once, while the applicant answers the bank's one round of follow-up questions ([below](#who-can-go-to-court)). The rule forces an answer, not a yes.
- **A second check during vacancies.** Any loan over $500,000 approved while the board lacks a quorum is checked by the independent auditor before any money goes out. If the auditor objects, the loan waits for the Treasury Secretary's written decision, which is published with the auditor's objection. If the Secretary hasn't decided within 30 days, the loan doesn't go out. It counts as a rejection by the bank, including in the rejection audit if the applicant scored above the lottery band.
### Who has the last word
The Constitution limits how far a lender like this can be insulated from the people the President appoints. Anyone who makes final decisions for the government has to answer, at some level, to officials the President appointed and the Senate confirmed. In 2021 the Supreme Court struck down a rule that stopped the head of the Patent Office from reviewing decisions by the judges below him, because "the unreviewable authority" of those judges was "incompatible with their appointment" as lower-level officers ([United States v. Arthrex, 2021](https://www.law.cornell.edu/supremecourt/text/19-1434)). The bank's credit officers hold permanent jobs with the power to commit up to $1 million of federal money on their own signature. Courts might well treat them the same way. That case was about officials deciding disputes, not lenders, and the credit officers might instead count as ordinary employees. The design below works either way.
So the design gives the last word to the board, and makes using it visible:
- **The board appoints the credit officers.** The board of a freestanding federal body likely counts as the "head of a department" that can appoint lower-level officers ([Free Enterprise Fund v. PCAOB, 2010](https://www.law.cornell.edu/supremecourt/text/08-861)). That case was about the SEC. Whether the board of a government corporation counts the same way hasn't been decided.
- **The board can review any staff decision,** approvals and rejections alike. A staff decision isn't signed for 15 days unless the board waives review of it in writing. Those days count inside the 120-day deadline, and inside the 60 days for new decisions after the audit. That way the board never has to cancel a signed loan.
- **Every overrule has names on it.** It's a recorded vote showing how each member voted, with reasons given file by file, never one memo for a batch. It's published along with any outside contact about that file. Loans the board decided over its staff's heads are reported as their own group. A board rejection of an applicant who scored above the lottery band counts in the [rejection audit](#when-rejections-mean-not-tested) like any other. A board approval of an applicant who scored below it, outside the lottery, counts in the approval check and falls under the rules against [political favoritism](pilot-safeguards.md#political-favoritism).
- **What that protects.** A board can block good loans or push weak ones, one at a time, but each is public. Enough blocked loans can make a fail "not tested," and pushed loans that go bad show up as broken standards if the [emergency stop](pilot-spec.md#the-decision-rule) fires. That protects the record, not the co-ops turned away. "Not tested" ends the bank just as a fail does.
- **Credit officers can be fired only for cause,** such as misconduct or breaking the filed standards. Only the board can fire them, with written findings that are published. For 140 years the Supreme Court has let Congress limit the firing of officials appointed by a department head ([United States v. Perkins, 1886](https://www.law.cornell.edu/supremecourt/text/116/483)). In July 2026 a federal trial court in Virginia held that this survives *Slaughter* (*Comans v. Executive Office of the President*, [reported by Lawfare](https://www.lawfaremedia.org/article/inferior-officers-protections-post-slaughter)). That's one court, and it binds no other.
- The administration argues the rule covers only officers with "limited duties and no policymaking or administrative authority," a line from [Seila Law v. CFPB, 2020](https://www.law.cornell.edu/supremecourt/text/19-7). Officers who can commit $1 million on their own signature may not fit that, so losing is at least as likely as winning.
- If it's lost, credit officers can be fired at will. Either way, the board can take back any officer's lending authority at any time, in public.
- **The President supervises through the board.** The law can't stop a president directing the board he can fire, or replacing it with people who need no instructions. What it can do is put a duty on the bank's side. Board members and staff log and publish every instruction or contact they get from anyone in the executive branch, the White House included, about a particular loan, applicant or group of applicants. Contacts from members of Congress are handled the same way.
- What this can't catch: orders given by phone and never mentioned, or a board replaced with people who agree. A law requiring the President's own messages to be published could also run into executive privilege. This rule catches careless pressure, not careful pressure.
**The same goes for the outside referees.** The reviewers and the auditor are private contractors. Private bodies can advise the government, but they can't have the final say over what it does. In 2025 the Supreme Court upheld a private body's role in the phone-subsidy program because its recommendations "cannot go into effect without an agency's say-so" ([FCC v. Consumers' Research, 2025](https://www.law.cornell.edu/supremecourt/text/24-354)). So:
- **The reviewers' score binds the staff, not the board.** Suppose the rejection audit finds an applicant was wrongly turned away. The new decision is made by a credit officer on the reviewers' score, following a rule the law sets for them. It then goes through the same 15-day board review as any other decision.
- The board can overrule it, in writing. The overrule counts as a wrongful rejection in the audit unless it names a disqualifier on the filed list, and the reviewers confirm the file backs it.
- That count is a report, not a veto. It measures whether the filed standards were applied, whoever applied them.
- A court might hold that even this gives the reviewers too much say. Then the score becomes advice to the staff, and a staff refusal simply counts in the audit the same way.
- **The results are reports. The board adopts them.** The reviewers' "failed" or "not tested" is what the record says, and on its own it orders no one. New lending ends on the date the law sets. Within 60 days of the final report, the board formally adopts the result. It can reject the reviewers' calculation only by publishing the specific error in how the filed rule was applied, which the auditor then checks in public. Once the result is adopted, a fail starts the loan sale, and a pass gives Congress its fast-track vote. Carrying out the sale after a fail is a duty with a deadline, so a court can order it. A friendly board could still refuse a fail on a made-up error, but only in writing, with the auditor's answer beside it.
- **Restarting after an emergency stop is up to the board.** The board decides in writing within 30 days of the reviewers' report. If the report says restart and the board refuses, it has to say so in public. A hostile board can end lending this way, and that can't be closed without taking the last word away from the board.
### Where the money sits
The $350 million, and the separate $100 million for [worker buyouts](buyouts.md), is paid into the bank as its own capital when it's chartered, not handed over year by year. The model says plainly what that does and doesn't protect.
- **It needs explicit legal cover.** By default, the Treasury keeps a federal corporation's accounts ([31 U.S.C. § 9107](https://www.law.cornell.edu/uscode/text/31/9107)). The farm co-op banks were exempted from that rule by name, and this bank's law has to do the same, so its money isn't held where one agency can freeze it.
- **Running costs come from the bank's capital and income**, as [the pilot](pilot-spec.md#how-much-money-and-why) already says, not from yearly appropriations. So nobody can starve it by refusing to pass a budget line.
- **The reviewers are paid in advance.** Money for the independent review is set aside from the capital on day one and paid to the reviewers by contract. A hostile government can't quietly disable the referee by not funding it.
- **What it doesn't protect.** Money not yet lent can still be taken back by Congress, the way the green-bank program's uncommitted money was. That takes a law, and it happens in public, but it can be a single line in a large budget bill passed by a simple majority. The pilot's capital will sit partly unlent for years, and that's exactly the kind of money that gets taken. Congress can also attach riders to spending bills that bar the bank from using its money. Neither can be prevented. The only defense is that doing it is visible.
### Loans already made keep their terms
Every loan is a binding contract. If a later law ends the bank, money already lent keeps its terms, and the loans are offered first to co-op and community lenders, as they would be after a failed test (see [the decision rule](pilot-spec.md#the-decision-rule)).
The wind-down has its own guards. Loans are sold for no less than an independent appraisal, only to lenders named in the bank's law (co-op lenders, credit unions and nonprofit community lenders), and they can't be resold to anyone else for five years. Otherwise a hostile Treasury could sell them cheaply to a front for a distressed-debt buyer.
Future stages of a loan that haven't been paid out are *not* promised. The contract says so in plain words. If Congress ends the bank, it doesn't owe anyone money for loans it never made, and one Congress can't lock a later one into paying out.
If a later law tried to change the terms of money already lent, there's precedent for the government owing damages. In *United States v. Winstar* (1996), Congress changed banking rules in a way that broke promises regulators had made to savings-and-loan companies that took over failing ones. The Court's lead opinion held that Congress was free to change the law, but the government still owed damages for breaking its contracts ([Winstar, 1996](https://www.law.cornell.edu/supremecourt/text/518/839)). It's only partial protection. There was no single majority opinion, the damages cases dragged on for years, and it only helps where the contract clearly puts the risk of a change in the law on the government. So the loan contracts say that explicitly.
### Who can go to court
Most of the rules above work without a judge. The verdict's wording follows the reviewers' findings, and the reviewers are paid in advance. The reports don't need a court either: if the bank doesn't publish its figures on time, the reviewers, who get the same data, publish them. But a few rules only work if someone can enforce them: deciding applications within 120 days, making the new decisions the [rejection audit](#when-rejections-mean-not-tested) requires, and paying out loans already signed. If a hostile board simply ignores them, someone has to be able to take it to court, and win something more useful than money years later.
Recent cases show the risk. In 2025 the Supreme Court said claims to restore cancelled research grants likely belonged in the Court of Federal Claims, which can award money but can't order a program to keep running ([NIH v. American Public Health Association, 2025](https://www.law.cornell.edu/supremecourt/text/25A103)). In the green-bank case, a panel of the D.C. Circuit said the same in 2025. In August 2026 the full court voted 6 to 4 to keep the order blocking the grants' cancellation, and the government is taking the case to the Supreme Court ([D.C. Circuit judgment, 2026](https://media.cadc.uscourts.gov/judgments/docs/2026/08/25-5122-2186441.pdf)). Which court can hear these cases, and what it can order, is itself being fought over. So the bank's law settles it for this bank instead of leaving it to the general rules.
**What a court can order.**
- **Only duties with a deadline.** The law says the bank *must* decide a complete application within 120 days, and must make each new decision the audit calls for within 60 days. Courts can order an agency to take a specific action the law requires of it ([Norton v. Southern Utah Wilderness Alliance, 2004](https://www.law.cornell.edu/supremecourt/text/03-101)). A court checks whether the duty was met on time, never whether a rejection was right. The reasons are checked by the [rejection audit](#when-rejections-mean-not-tested), not by judges. The one exception is a new decision after the audit. The law already says the staff's answer there is yes unless the reviewers confirm a disqualifier, so a court can probably order the staff to follow that rule. The board keeps its 15-day review of the result, and a written overrule by the board stands. It's counted in the audit instead.
- **No endless paperwork.** Whether an application is complete is judged against the bank's published checklist. The bank gets one round of follow-up questions, and the application is complete once the applicant answers them. Otherwise the 120-day clock could be stopped forever and there'd be nothing for a court to order.
- **Signed loans, from money that's still there.** A borrower can make the bank pay out a stage of a signed loan once that stage's written conditions are met. Those conditions have to be objective and written into the contract, not left to the bank's judgment. The court can order payment only from the bank's own capital. If a later law, including a rider in a spending bill, bars the payment, no court can order it ([OPM v. Richmond, 1990](https://www.law.cornell.edu/supremecourt/text/496/414)). That's the same line drawn [above](#loans-already-made-keep-their-terms).
- **The right court.** The law lets the government be sued for these duties in federal district court, which can order the bank to act, not only award money, even where the general rules would send the case to the Court of Federal Claims. Congress decides where the government can be sued and for what. It hasn't been tested, and a later Congress could repeal it, but only by passing a law.
**No friendly settlements.** No court order or settlement can require the bank to approve a loan, apart from following the audit's own rule, or change a filed standard. Every settlement is published and reported to Congress. That stops a friendly board from inviting a lawsuit and agreeing to loosen its own standards, and it stops a hostile one from settling its duties away.
**Who can sue.** Applicants and borrowers who were harmed. Since 2025, a court's order usually helps only the people who sued ([Trump v. CASA, 2025](https://www.supremecourt.gov/opinions/24pdf/24a884_8n59.pdf)). Class actions may still be possible against a bank-wide practice, like a freeze on all decisions, but probably not over many different files. The federation can sue for a member co-op that was harmed, as membership groups already can, but that doesn't help groups that aren't members. Courts must hear deadline cases quickly, since a founding group waiting on a decision can break up long before a normal lawsuit ends.
**Legal costs.** A founding group can't afford to sue a federal bank. Small businesses and co-ops that win against the government can sometimes recover their lawyers' fees, but at capped rates, and not if the government's position was "substantially justified" ([28 U.S.C. § 2412](https://www.law.cornell.edu/uscode/text/28/2412)). So when the bank has missed a deadline, which is a plain fact, it pays the winner's reasonable fees in full. All of the bank's legal costs count against its money test, win or lose, like any other running cost.
**If the government won't defend the law.** Someone could challenge the bank's law itself, and a hostile Justice Department could decline to defend it, or settle. The Attorney General already has to tell Congress within 30 days of deciding not to defend a law ([28 U.S.C. § 530D](https://www.law.cornell.edu/uscode/text/28/530D)). The bank's law adds settlements to that, and requires notice to the federation, the lock-holders and every borrower. After that, the options are weak:
- The House defended the Defense of Marriage Act in 2013, but the Supreme Court never decided whether it had the right to. The Court has since held that one chamber of a state legislature can't appeal on its own to defend a state law ([Virginia House of Delegates v. Bethune-Hill, 2019](https://www.law.cornell.edu/supremecourt/text/18-281)), and one house of Congress would likely be treated the same way. And a hostile House wouldn't try.
- Private people can't appeal to defend a law just because they care about it ([Hollingsworth v. Perry, 2013](https://www.law.cornell.edu/supremecourt/text/12-144)). Borrowers with signed loans probably have enough at stake to defend their own loans. The federation and applicants may not.
**Why the bank doesn't get its own lawyers.** Some federal lenders can go to court in their own name. Here that would add little. After *Slaughter* the President controls the board, and a board that doesn't want to defend the bank won't. Going to court for the government is also an executive power, so lawyers allowed to fight the President in court would be challenged themselves. The power to enforce these rules sits with the people they protect, not with the bank.
### The lock doesn't depend on Washington
[The lock](the-lock.md) lives in each co-op's own charter and is enforced by an independent lock-holder, not by the bank. So repealing the bank's law doesn't unlock a co-op in ordinary times. There's one big exception. The lock only holds in bankruptcy if federal bankruptcy law is amended (see [When a Locked Co-op Goes Bankrupt](bankruptcy.md)), and a Congress that repeals the bank could repeal that amendment too.
States are the second layer. Co-op law in America is state law. Massachusetts, for example, has had a law for worker co-ops since 1982, but a co-op can leave that status by a two-thirds vote of its members ([Mass. Gen. Laws ch. 157A, § 4](https://malegislature.gov/Laws/GeneralLaws/PartI/TitleXXII/Chapter157A/Section4)). That's exactly the exit the lock closes. So the model includes a short [model state law](lock-state-law.md) that recognizes locked co-ops and their lock shares, turns off the members' exit for co-ops that chose the lock, and makes lenders' sales outside court follow the lock's rules. States adopt it or don't. Nothing in the federal law requires it. It only protects co-ops that chose the lock, in exchange for a loan they chose to take.
### Federal test, state copies
[Open Problems](open-problems.md) asked whether the bank should be federal or run by states. The answer here is both, for different jobs:
- **The test is federal.** One bank, one plan and one set of reviewers make one clean experiment. Fifty small ones, each designed a little differently, can't be added up into one answer.
- **Any state can copy it.** A state can charter its own co-op lender under the same published rules. A state lender can even be given a steady base the way North Dakota's is, by holding some state money on deposit. Its results are reported separately and don't count toward the federal test in either direction. If Washington abandons the idea, the states that copied it keep running.
### The sector pays its own way
The last layer is the only one that doesn't depend on anybody's goodwill: the co-ops' own money. The [federation](federation.md)'s shared fund is built to become a lender itself, the way Italy's Coopfond has lent to co-ops for three decades out of their own profits ([Has This Been Tried?](precedents.md)). The sooner co-ops can finance each other, the less a hostile Congress can do.
## Why a hostile government might leave it alone
The Bank of North Dakota survived because the side that had fought it decided to give it a fair trial. This model tries to make that the cheaper choice for an opponent:
- **It's a one-time cost.** There's no yearly spending line to fight over.
- **It shuts itself down if it fails.** An opponent who expects co-ops to fail can let [the decision rule](pilot-spec.md#the-decision-rule) prove it, and the rules above make it hard to rig that result either way.
- **It buys no one's company.** It still spends public money, and conventional competitors and some employers will oppose it anyway. Italy's main employers' association got part of its worker-buyout program suspended by the EU ([Has This Been Tried?](precedents.md#italys-co-op-funds-coopfond-and-the-marcora-law)). But no industry loses its property.
- **Every result is public,** so whoever ends it early has to explain why.
None of that stops a determined opponent. It only raises the cost of acting in bad faith.
The electric co-ops show the longer-term defense. If the test passes and the sector grows, it will eventually have members in most congressional districts. People who own part of something defend it. The same defenders can also lobby to keep a program running after it's stopped earning its place. That's why the pilot's growth depends on a fixed test, not on how many friends it has.
## What this can't protect
- **The bank itself.** A Congress and President who both want it gone can repeal it. Nothing here prevents that, and nothing should. A democracy has to be able to change its mind. The design only makes sure the repeal is done by law, and that it can't take the co-ops' charters with it.
- **Money not yet lent,** which a budget bill can take back, and riders that bar spending.
- **A board that's merely timid.** The rules stop a freeze and expose rejecting good applicants. They can't make a hostile board eager, stop it hiring timid staff, or stop it pulling back the credit officers' authority in public.
- **A small constituency.** At pilot scale, around 400 co-ops employ perhaps several thousand people. That's nowhere near enough to defend anything politically. The electric co-ops became defenders after decades and hundreds of co-ops. Until this sector is large, durability comes from the co-ops' own charters and money, not from votes. How long it could take to get large is worked through in [After a Pass: How Big, How Fast](scale.md).
- **Pressure outside the law.** Other banks refusing to work with co-ops, co-ops shut out of government contracts, targeted inspections. The federation and co-op-friendly lenders like credit unions are the only real answers, and they take time to build.
- **The courts.** How far *Slaughter* reaches, whether damages follow when a later law breaks a loan contract, and whether the Supreme Court sends cases like these to the Court of Federal Claims despite the bank's own law will be settled by judges, not by this design. Lawsuits are also slow, both sides can use them strategically, and even with fees paid, the people with the most reason to sue are founding groups with the least time to wait.
@@ -0,0 +1,118 @@
---
title: Who Decides What's Essential
section: Beyond Co-ops
order: 112
summary: A public subsidy for "essential" low-paid work invites every low-wage industry to call itself essential, so a fixed formula decides which work qualifies, the money goes to workers by the hour, and states can't use it as a reason to pay less.
---
# Who Decides What's Essential
[The floor](the-floor.md) includes a targeted subsidy for essential low-paid work, paid to workers, not firms. Somebody has to clean the nursing home for the poor, and if the work can't pay a decent wage, the public should.
The danger is in the word "essential." Whoever decides which work counts gets to set part of the price of labor in that industry. Every low-wage industry will discover that it's essential, and every one has lobbyists.
## How fast "essential" grows
We've seen it happen. Early in the COVID-19 pandemic, the federal government published an advisory list of "essential critical infrastructure workers" who should keep working. Researchers at the CDC's occupational safety institute later matched it to standard industry codes. It covered industries employing about 81% to 87% of American workers, depending on how industries are counted ([Billock and others, 2022](https://pubmed.ncbi.nlm.nih.gov/35532007/)). That list was only advice about who could keep working, with no money attached, and "essential" still ended up meaning nearly everyone. A list that came with money would be under even more pressure.
## Who really gets the money
The second danger is quieter. Money aimed at workers can end up with employers.
The Earned Income Tax Credit tops up low earners' pay through the tax system. Because it draws more people into low-wage work, it pushes those wages down. One careful simulation, using a range of plausible assumptions about how workers and employers respond, found that each dollar spent raises workers' after-tax incomes by only about 73 cents. Employers capture much of the rest through lower wages, partly at the expense of low-wage workers who don't get the credit at all ([Rothstein, 2010](https://www.nber.org/papers/w14966)).
Washington, D.C., tried a more direct route. Its Early Childhood Educator Pay Equity Fund paid child care workers supplements of $10,000 to $14,000 a year, sent to the workers themselves, outside their employers' payrolls. More than 4,000 educators received them, and over $80 million went out in the first two years ([D.C. OSSE](https://osse.dc.gov/ecepayequity)). An independent study found the fund added about 219 child care educators, around 7% more than there would otherwise have been. Employers' own pay didn't change, so in those two years they didn't claw the money back by cutting wages ([Mathematica, 2024](https://www.mathematica.org/publications/two-year-labor-market-impacts-of-washington-dcs-early-childhood-educator-pay-equity-fund)). Two years is short, though, and D.C. has since switched to paying the money through employers' payrolls. D.C. also shows a limit of what follows: child care is mostly paid for by families, so the test below may leave it out.
Minnesota took a third route, and it gives workers a voice. In 2023 it created a board of three state officials, three employer representatives and three worker representatives, with the power to set minimum pay for nursing home workers. The board set $22.50 an hour for certified nursing assistants and at least $19 for everyone else, rising in 2027. The state pays for it through the rates Medicaid pays nursing homes, and it took effect in September 2026 after federal approval ([Minnesota Nursing Home Workforce Standards Board, 2025](http://www.dli.mn.gov/sites/default/files/pdf/nhwsb_annual_report_2025.pdf); [RMCEP](https://www.rmcep.com/success-stories/news-archive/p/item/69158/minnesotas-minimum-wage-standards-for-nursing-home-workers-in-effect-sept-10)).
## The model's answer
### A formula decides, not people
No official, agency or Congress picks industries by name. Work qualifies only if it passes two tests, measured from published government data:
1. **The public already pays for most of it.** Over the last three years, at least half of the industry's revenue came from public benefit programs like Medicaid, Medicare, veterans' care and public child care assistance. Ordinary government purchasing, like cleaning or security contracts, doesn't count. How that's measured, and which kinds of work pass today, is below.
2. **The job pays less than most.** The typical hourly wage for the job, within that industry, is below the typical wage for all American jobs. In May 2025 that was about $24.50 an hour ($50,980 a year). Home health and personal care aides earned a typical $17.21 an hour, and nursing assistants $20.32 ([BLS, home health and personal care aides](https://www.bls.gov/oes/2025/may/oes311120.htm); [BLS, nursing assistants](https://www.bls.gov/oes/2025/may/oes311131.htm)). The test is by job, not by industry, so the nurses and managers in a nursing home don't pull the aides' number up, and splitting staff into separate companies doesn't change anything.
The first test is the important one, and it's really a test of who pays, not of what's "essential." That's on purpose: it's the one test lobbying can't argue its way around. Where the public is already the main buyer, the public is already setting the price, and a low public rate is a choice about what these workers are paid. It also keeps the program narrow. Retail, restaurants and other low-wage work paid for by private customers don't qualify. They're covered by the parts of the floor that apply to everyone, like the minimum wage and tax credits. Averaging over three years means an industry can't tip itself over the line in a single year.
The national statistics agencies apply the formula every three years and publish the list, with old industry codes mapped to new ones when the codes are revised. Congress can change the formula, but only for everyone at once, with a published cost estimate. It can't add one industry by name.
### Measuring who pays
The first test needs data on who pays each industry's bills. That data exists for some kinds of care, is misleading for others, and is missing for the rest.
- **The Census Bureau's yearly survey of service businesses** splits health care industries' revenue by who paid it ([Service Annual Survey, Table 4, 2022 release](https://www.census.gov/data/tables/2022/econ/services/sas-naics.html)). But it counts payments from private plans that run Medicare and Medicaid for the government, like Medicare Advantage and Medicaid managed care, as "private health insurance." In 2021, 24 states paid for Medicaid long-term care through those plans ([MACPAC](https://www.macpac.gov/subtopic/managed-long-term-services-and-supports/)), so the survey undercounts public money.
- **The federal health spending accounts** count those payments as Medicare and Medicaid, where they belong ([CMS methodology](https://www.cms.gov/Research-Statistics-Data-and-Systems/Statistics-Trends-and-Reports/NationalHealthExpendData/Downloads/dsm-17.pdf)). But they're organized by type of care, not by industry, and some categories mix several industries.
- **For social assistance, like child care and non-medical home care, the Census survey doesn't ask who pays at all.**
**What the two health sources show.** The survey column counts only payments made directly by Medicare, Medicaid and other government programs, so it's a floor. The spending accounts column counts managed care too, but its categories are broader.
| Kind of work | Census survey, 2020–22 average | Health spending accounts, 2021 |
| --- | --- | --- |
| Home health care | About 63% | About 72% |
| Nursing homes | About 66% | About 56%, combined with retirement communities |
| Residential care for developmental disabilities, mental health and addiction | About 67%, but only 45% from Medicare and Medicaid. The rest is other government programs, mostly state ones | Part of a mixed category that's about 61% public |
| Assisted living and retirement communities | About 16% (2020–21; 2022 withheld). Families pay nearly half | Combined with nursing homes, above |
| Hospitals | About 34% (2020–21; part of 2022 withheld) | About 52% |
| Child care | Not measured | Not covered |
Sources: Census [Service Annual Survey, Table 4](https://www.census.gov/data/tables/2022/econ/services/sas-naics.html), 2022 release; [MACPAC, from the national health spending accounts](https://www.macpac.gov/wp-content/uploads/2023/12/EXHIBIT-3.-National-Health-Expenditures-by-Type-and-Payer-2021.pdf), counting Medicare, Medicaid, CHIP and veterans' and military care.
Three things stand out:
- **Home health passes on any measure.** Non-medical home care, where most personal care aides work, isn't measured by industry at all. Medicaid paid about 60% of the broader category that includes most home and community-based care in 2023 ([MACPAC](https://www.macpac.gov/medicaid-101/spending/)), so it very likely passes, but real data has to show it.
- **Counting managed care properly puts hospitals near the line.** Because the wage test is by job, a hospital that qualifies brings in its low-paid aides, housekeepers and cooks. That's consistent with the rule, since the public does pay for most hospital care, but it would add a great deal to the cost. It's shown here so nobody is surprised by it later.
- **Child care is the hard case.** The US Treasury estimated that federal and state spending equaled about 47% of child care businesses' revenue in 2019, and said that probably overstates the public share, since some of that money pays for care outside those businesses ([US Treasury, 2021](https://home.treasury.gov/system/files/136/The-Economics-of-Childcare-Supply-09-14-final.pdf)). Pandemic relief money pushed the share up for a few years. Until there's real data, child care's status is undetermined.
**So the law fixes the method in advance:**
- **Which money counts.** A closed list: Medicare and Medicaid, including their private plans; CHIP; veterans' and military health care; the Indian Health Service; state programs for people with disabilities; child care assistance and Head Start. Only money paid to businesses in the industry counts. Workers' compensation, tax credits, Social Security benefits paid on a resident's behalf (which are the resident's own income), ordinary government contracts, and temporary emergency money don't. Changing the list follows the same rule as changing the formula: for everyone at once.
- **Measured from payment records.** Public money comes from the programs' own payment records, matched to businesses by tax ID, not from what businesses say in a survey. Revenue comes from the Census survey, counting revenue from care, not donations or investment income. Where the Census withholds a figure, the last published one is used, and flagged.
- **New data, on a deadline.** The Census survey has three years to report Medicare and Medicaid plan payments separately from private insurance, and to ask child care and home care businesses who pays them. The cost comes out of the supplement's own budget.
- **Status is sticky.** An industry joins the list only after two reviews in a row at 50% or more, and leaves only after two reviews in a row below 45%. So sampling noise near the line, or one year's emergency money, can't flip it. A newly qualifying industry is paid only after its cost estimate has been public for a full budget year.
- **No losing status for missing data.** If a list isn't published on time, the last one stays in force. When industry codes are revised, an industry keeps its status for two reviews while the new codes are mapped, and the mapping is published for comment first.
- **No moving into a qualifying industry.** A company that works mainly for a related business takes that business's industry, and industry codes are checked against state licensing. An assisted living operator can't qualify by moving its aides into an affiliated home health agency.
### Paid to workers, by the hour
- **Straight to the worker,** as a fixed amount per hour worked, as D.C. did at first.
- **Real hours only.** Federal payroll forms report pay, not hours. So hours come from records that already exist for public programs, like the electronic visit records federal law has required for Medicaid home care since 2020 ([Medicaid.gov](https://www.medicaid.gov/medicaid/home-community-based-services/guidance/electronic-visit-verification-evv)), and from employers' own payroll hours where those aren't available. Hours are capped at 2,600 a year per worker, about 50 a week, across all their jobs. Live-in workers are paid for the hours their state program or wage law already counts as work. Samples are audited, and when padded hours are found, the employer repays.
- **Everyone doing the work counts,** including home care workers employed directly by the people they care for through a state program, and self-employed providers paid by a public program. Their hours come from the program's own records.
- **How much.** A first draft: $3 an hour, set in law. That's enough to take a typical home care aide from $17.21 to about $20.20, roughly what a typical nursing assistant earns now, and an aide in the bottom tenth from $13.00 to $16.00 ([BLS](https://www.bls.gov/oes/2025/may/oes311120.htm)), if employers don't claw any of it back (see [below](#stopping-the-money-from-leaking)). It's a budget choice, not a living wage. It rises each year with average wages, not prices, so it doesn't shrink against the pay it tops up. Every three-year review publishes vacancies, turnover and pay in qualifying work next to the amount, so Congress can see whether it's doing anything.
- **The same amount for everyone in a qualifying job.** An earlier draft faded it out as a worker's own pay rose. That would have taken a cut of every raise, including raises a state or wage board ordered, and pay in these jobs is bunched too tightly for it to save much: nine in ten home care aides earn under $21.65, and nine in ten nursing assistants under $25 ([BLS](https://www.bls.gov/oes/2025/may/oes311131.htm)). It would also have needed an hourly rate for every worker, which doesn't exist for live-in, day-rate or self-employed providers. So the only way off is the job-level test above: when typical pay in the job rises past the national median, the job leaves the list.
- **How it's taxed.** It's paid like wages: income tax, payroll tax and Social Security credit, and it counts as earned income for the Earned Income Tax Credit, as any other pay does. A parent in the part of that credit that shrinks with income keeps roughly 60 cents of each dollar after all of it. But the law excludes the supplement from income when deciding eligibility for Medicaid, CHIP, marketplace health subsidies, food assistance, SSI and housing help, so it can't knock anyone off them. That takes a line in each program's rules, and it costs money that hasn't been estimated.
- **Who can get it.** Anyone allowed to work in the US at the time, with their own Social Security number. Authorization is checked each quarter against Social Security and immigration records. A mismatch, like a name change, gets 90 days to fix before any payment is held, and hours worked while authorized are always paid. That needs saying in the law itself. Federal law bars immigrants who aren't "qualified aliens" from "any grant" or "similar benefit" paid with federal money ([8 U.S.C. 1611](https://www.law.cornell.edu/uscode/text/8/1611)), and that leaves out many people who are allowed to work, like those with Temporary Protected Status or DACA. So the supplement's law says it applies to anyone authorized to work, notwithstanding that rule, and that states can't exclude them either. That's a choice Congress could split off from the rest. The law also says the supplement isn't a public benefit for immigration purposes, so families don't avoid it out of fear.
- **Same for everyone.** Co-ops and conventional firms qualify on exactly the same terms. The subsidy doesn't favor co-ops, and it doesn't skew [the pilot's](pilot-spec.md) comparison with conventional firms, because both sides get it.
### Stopping the money from leaking
Money aimed at workers has three ways out. Each gets its own rule.
- **The public payer.** The biggest risk is that states cut their Medicaid rates, knowing the federal supplement will fill the gap. So a state's rates for qualifying care can't fall behind inflation. If they do, the federal share of the state's Medicaid spending on that care drops by the same amount. The supplement also can't count toward any rule requiring a share of rates to go to wages, like [Medicaid's 80% rule for home care](high-turnover.md#when-medicaid-decides-how-much-goes-to-pay), and it's left out when rates are set.
- **The employer.** An employer can't let any job's total pay, wages plus benefits, fall behind inflation compared with the year before its workers qualified. One that does repays the difference to its workers, with a penalty. Pay in qualifying work is compared every year with similar work that doesn't qualify, so slower raises show up. And the supplement can't be counted toward any minimum wage, and a state or wage board can't set its minimums lower because of it.
- **The owner.** In industries full of related-party rent and management fees, public money that frees up payroll can still end up with owners. The supplement doesn't fix that. It's a reason for the rate rules states set, and for co-ops.
### The budget
The supplement is a fixed amount per hour, set in law and rising with average wages, so it doesn't shrink when Congress wants to starve it quietly. The cost is whatever that amount times the qualifying hours comes to, published every year. When the formula adds an industry, the cost rises, and everyone can see by how much.
For scale, in May 2025 employers reported about 4.4 million home care aide and nursing assistant jobs in industries likely to qualify: about 2.3 million in non-medical home care and similar services, 1.1 million in home health, 590,000 in nursing homes and 330,000 in residential care for people with disabilities or mental illness ([BLS, aides](https://www.bls.gov/oes/2025/may/oes311120.htm); [BLS, nursing assistants](https://www.bls.gov/oes/2025/may/oes311131.htm)). Those are jobs, not people, since some people hold two. At $3 an hour and 1,200 to 1,500 hours a year each, that's about $16 billion to $20 billion a year. Not included: aides hired directly by the people they care for, who don't all show up in these counts; self-employed providers; about 510,000 hospital aides and nursing assistants, plus hospital housekeepers and cooks, if hospitals qualify; and the cost of leaving the supplement out of benefit tests. About 430,000 more jobs are in assisted living, which doesn't qualify on today's numbers. Each dollar an hour on the amount adds about a third.
### Worker voice, too
A formula keeps lobbyists out, but it also leaves workers out. Minnesota's board shows another way: workers, employers and the state set minimum pay together, and the public rates pay for it. The two fit together. States can set up boards like Minnesota's for qualifying work, and the federal supplement comes on top, because states can't cut their own spending.
### A way off the list
The founding plan pairs this subsidy with public research on automating the same work, and says plainly that it's meant as a bridge, not forever ([Ownership and the Floor](the-floor.md)). The formula makes the exit automatic. The wage test is measured without the supplement, so the supplement itself can't push an industry off the list. When real pay rises, because of automation, higher public rates or anything else, the industry drops off, fading out over two reviews rather than all at once. For care work, the bridge will probably be long. It ends when the public pays what the work is worth.
## What this doesn't solve
- **Some essential work won't qualify.** Where families, not the public, pay most of the cost, the formula leaves the work out. That may include a lot of child care, where D.C.'s evidence comes from. Families who pay for care themselves, and people who care for relatives unpaid, get nothing from this. That's the price of a rule that can't be bent. States can still do what D.C. did.
- **The formula will be fought over.** Industries will argue about how their work is classified and what counts as public revenue. Checking workers against public program records, not just the codes employers pick, is the defense. It isn't perfect.
- **Capture isn't ruled out.** The rules catch pay cuts, including cuts in benefits, and states cutting their rates. They can't catch every raise that would have happened anyway.
- **It leaves out workers who aren't allowed to work here.** That's a political choice, not something the law forces: paying people for work they can't legally be hired for would be hard to pass, and this page doesn't pretend otherwise. It's worse than leaving them out. By the logic of the tax-credit research above, a supplement that draws more authorized workers into care work can hold down the wage employers pay, and workers who don't get the supplement bear that with nothing to make up for it. Many care workers are immigrants, and undocumented ones are more likely to work off the books, where no program reaches them ([PHI](https://www.phinational.org/policy-research/key-facts-faq/)). What protects them is the rule against employers cutting a job's pay, and minimum pay set by state wage boards like Minnesota's, which covers every worker in the job whatever their status.
- **It costs real money.** Tens of billions of dollars a year is plausible once home care and nursing homes qualify, and much more if hospitals do. That's a choice about what care workers are paid, and the page states it so voters can make it.
- **It's federal money for work states largely pay for.** States already can, and some do, raise care workers' pay themselves. The federal role here is to make that pay not depend on where you live, and to stop states using it as a reason to pay less. That sits uneasily with leaving child care out: its workers are overwhelmingly women, and if it doesn't qualify, their pay still depends on their state.
- **Congress can still change the formula.** It's harder to bend than a list, not impossible.
@@ -0,0 +1,21 @@
---
title: The Federation
section: Growing the Sector
order: 70
summary: Co-ops pool risk through a shared fund that absorbs shocks, moves workers when a firm fails, and becomes a lender in its own right.
---
# The Federation
Worker ownership has a risk problem that's easy to miss: **concentration.** It matters even more in a young, voluntary co-op sector.
A worker-owner holds their salary, their profit share, and their skills all inside one firm. That is exactly the risk a shareholder diversifies away and a worker cannot. It is the real source of risk-aversion: if one bad bet can kill the company that is also your paycheck and your equity, you take the safe option every time, and an economy of safe options stops producing breakthroughs.
The fix is a structure between firms, and Mondragon already built it:
- **Firms pool a slice of profits into a mutual solidarity fund.** The fund absorbs shocks. A firm having a bad year draws support instead of dying.
- **When a firm does die, the federation redeploys its workers into member firms.** Your company failing stops meaning your life failing. *This* is what licenses healthy risk-taking: not lower stakes on paper, but a floor under the fall.
- **The fund becomes a second lender.** Over time, the federation (financed by its members and by the indivisible reserves of dissolved co-ops) becomes a source of patient capital that doesn't depend on the public bank at all. That is the path to the co-op sector outliving the political coalition that started it.
- **Federations can insure their members' savings.** A federation can run its own savings insurance pool, as long as it meets the national standards and reinsures with the others. Its co-ops' workers are then covered close to home (see [Protecting Members' Savings](savings-guarantee.md)).
Membership is voluntary, but the incentives all point in: unfederated co-ops face the concentration problem alone.
@@ -0,0 +1,84 @@
---
title: The Founder Problem
section: The Co-op
order: 40
summary: Without the chance to sell the company, why would a strong founder start a co-op? The numbers, the tools, and the honest answer.
---
# The Founder Problem
This is the hardest part of the design, and it needs to be said plainly.
**In a conventional business, a founder who succeeds gets paid in two ways: a share of the profits every year, and a big payday when the company is sold.** A co-op founder gets neither in full. The profits are shared with every member, and [the lock](the-lock.md) means the company can never be sold. So the obvious risk is *adverse selection*, meaning the pool you get is the one nobody else wanted: the best entrepreneurs go conventional, and the bank's applicants are the people who couldn't raise money anywhere else. If that happens, the experiment is rigged to fail before it starts.
This page runs the numbers, lays out the tools, and says honestly what they can and can't fix.
## The numbers
Take the same business, with five founders who all draw normal salaries, built two ways:
- **As a conventional firm:** the five founders own it outright, financed by a bank loan. Each year they pay out 60% of profit to themselves and keep 40% in the business to fund growth. At year 15 they sell it for three to six years' profit.
- **As a co-op:** the five founders are the first members. The profit split is the same (60% to members, 40% kept in the reserve and the federation fund), but it's shared by everyone who joins, and nobody sells. (See [Member Capital Accounts](capital-accounts.md) for how the money flows.)
Here's what **one founder** has received by year 15 if the business succeeds, on top of salary. Money received later counts for less than money now. It's discounted at 5% a year, the rate used in the founder study below.
| Kind of business | Profit per worker (after salaries) | Size at year 15 | **Co-op founder** | **Conventional founder** (one-fifth owner) |
| --- | --- | --- | --- | --- |
| Care work | $3,000 | 40 people | about $19,000 | $140,000 to $180,000 |
| Main Street | $15,000 | 40 people | about $96,000 | $710,000 to $880,000 |
| Strong | $40,000 | 80 people | about $260,000 | $3.2 million to $4.2 million |
| Very high margin | $150,000 | 200 people | about $980,000 | $29 million to $38 million |
**In the success case, a co-op founder ends up with somewhere between about a seventh and a fortieth of what the owner of the same firm would.** The bigger and more profitable the firm, the wider the gap. Nothing on this page makes it go away.
Three caveats about these numbers:
- **They assume the firm exists and works equally well either way.** That's the big assumption. If co-op ownership makes firms less productive, or if the firm would never have been founded as a co-op, the comparison is moot. The first is exactly what [the pilot](pilot-spec.md) measures. The second is the founder problem itself.
- **They're a simple model, not a forecast.** Founders start at a full share. New hires join as members after three months and ramp up to a full share over five. The part of a share they don't get yet goes to the reserve, as tax law requires, not to the founders ([How a Locked Co-op Is Taxed](taxes.md#the-tenure-ramp)). An earlier version of this table let it go to the other members, and showed co-op founders with roughly twice as much. Payroll tax on the founding share is left out too. The workforce grows steadily to the size shown. Change the growth rate or the sale price and the ratios move, but the gap never comes close to closing.
- **Taxes are left out on both sides.** Counting them would widen the gap, not close it. The owner of a partnership or S corporation can usually deduct up to 20% of the business's profit, which a co-op member can't ([How a Locked Co-op Is Taxed](taxes.md#how-this-compares-with-other-businesses)). In most industries, a founder who sells shares in an ordinary small corporation after five years can leave up to $15 million of the gain untaxed, or ten times what they put in if that's more ([26 U.S.C. § 1202](https://www.law.cornell.edu/uscode/text/26/1202)), and a co-op founder has no sale to leave untaxed.
## Where the money went
In the very-high-margin case, a worker who joined in year three has received about $600,000 by year 15 (see [Member Capital Accounts](capital-accounts.md#what-it-adds-up-to)). The people hired after them have less, because they've had fewer years at full share, but all 200 are building a share of what the business earns.
**If the firm exists either way, the founder's "missing" money isn't lost. It went to the people who did the work.** That's the whole model. A version of the co-op that paid founders like owners would just be a conventional firm with extra steps.
So the real question isn't "can a co-op match a conventional exit?" It can't. The question is: **are there enough good founders for whom the co-op deal is good enough?**
## Why it's less lopsided for most founders
The table compares a co-op with a conventional firm *that succeeds*. Most don't. And for a founder without much money, a big risky payoff is worth far less than its average.
- **Most venture-backed founders get nothing from the exit.** Robert Hall and Susan Woodward studied nearly every venture-capital-backed US startup from 1987 to 2008, about 22,000 companies. The average founder got $5.8 million at exit, but **almost three-quarters got nothing at all**. A few enormous wins drive the average. For a founder with $100,000 in savings and an ordinary aversion to risk, they calculated that the chance to found a venture-backed startup was worth *slightly less than nothing* compared with a salaried job ([Hall and Woodward, *American Economic Review*, 2010](https://web.stanford.edu/~rehall/HallWoodward6.pdf)). That study covers tech and biotech startups, not the small businesses the bank would fund, but the logic carries over. A long-shot payday is worth much less than its average to someone who can't afford to lose.
- **Most small-business owners earn less than they would as employees.** Barton Hamilton found that after ten years in business, the median self-employed person earned 35% less than in a paid job. That counts the growth in value of their business ([Hamilton, *Journal of Political Economy*, 2000](https://www.journals.uchicago.edu/doi/abs/10.1086/262131)). The median small business that does sell goes for about $345,000 ([BizBuySell, 2024](https://smallbiztrends.com/bizbuysell-insights-report-q4-2024/)). Hamilton read the gap as people paying to be their own boss, and co-op founders give up some of that, since a co-op founder is one vote among many.
- **The co-op's downside is smaller.** A co-op that fails costs its members their jobs and what's left in their accounts. Under [the pilot's loan terms](pilot-spec.md#loan-terms), members give no personal guarantees, while SBA loans require one from every owner of 20% or more. That's a feature of how the bank lends, not of co-ops as such. It still matters to the founder deciding which to start.
So for a founder *without* family money, the co-op isn't simply a worse lottery ticket. It's a different bet: a much smaller top prize, a lower chance of losing everything, and a salary throughout.
**Where the co-op really does lose is at the top:** the founder who is confident, wealthy enough to absorb a failure, and building something with very high profit per worker. That founder will usually go conventional, and the model should expect them to.
## The tools
These are the ways a co-op can reward founders without selling the firm. Each is limited so it can't turn a founder back into an owner, or let founders pay themselves ahead of the bank.
- **Member capital accounts.** A founder builds up an account like every other member, from year one. See [Member Capital Accounts](capital-accounts.md).
- **A founding share.** For the first ten years, 10% of the members' profit pool goes to the people who were there at the start. It's split by the hours each person worked in the first three years, counting everyone on the payroll, members or not, so the first hires share it with the founders and keeping early workers out of membership doesn't help. It's paid only to people still working at the co-op that year. When someone leaves, their part goes back to the pool. The reserve's and the federation's shares are figured before it, so it comes out of the members' side only, and nobody's founding share in a year can be more than the going-rate pay for their job. Because it goes to some workers and not others, it's paid as pay, not as a profit share ([How a Locked Co-op Is Taxed](taxes.md#the-founding-share)). So the reserve takes 30% of it, like any pay above the going rate ([Investing for the Long Run](the-long-run.md#the-rules)), and it counts in the pay ratio. In the table it's about a tenth of what a founder receives. It is a claim on the work of people hired later, which is what the thesis objects to. The limits keep it small: ten years, a fixed slice, and only while you still work there. A fixed slice means founders gain nothing from keeping the co-op small later on. They would gain a little from hiring late in the first three years, since fewer early hours means a bigger share of the slice for each.
- **Back pay for unpaid early work.** If founders take reduced pay before the business has revenue, the difference is recorded as a debt the co-op owes them. Pay can't go below the minimum wage once the co-op exists ([Owners Are Still Workers](labor-law.md#founders-unpaid-work)). The limits:
- It's valued at no more than the pay the co-op sets for the same job once it has revenue.
- It earns interest at the Treasury rate.
- It ranks behind the bank, can't be repaid from the bank's loan money, and isn't repaid while any bank payment is late.
- **Founder money.** Cash a founder puts in comes in one of two forms. It can be a loan at no more than the Treasury rate plus three points. Or it can be the same voteless, expiring investment any outside investor could make under [Capital, After](capital.md). Either way it ranks behind the bank. A founder who remortgaged their house to start the co-op can take the investor's terms, which reward that risk.
- **Founder salary premiums.** A founder who is also the chief executive can be paid like one. The founding bylaws can set that pay for at most the first five years. After that, the members set it, like every other salary.
- **No founder vetoes.** Founding bylaws can't give founders extra votes, vetoes, or rules requiring a supermajority to remove them. Founders write the first bylaws before any other worker exists, so this is the one place the model protects future members from them.
**What's deliberately not here: a founder's tail.** One idea considered was letting founders keep a voteless share of profits for some years after they leave, as a stand-in for the sale price. It was left out. It would be a claim on the work of people the founder no longer works alongside, which is exactly what [the thesis](thesis.md) says this kind of firm exists to prevent. In the largest case it would also have paid each departed founder several times what a working member earns.
## The honest bottom line
None of this replaces a large exit. The model is betting on three kinds of founders:
1. **People without wealth** who would never risk a conventional startup. For them, a salaried, lower-risk path to building something may be the best deal available.
2. **Groups of workers founding together.** When the "founders" are the first ten employees, the gap between founder and worker mostly disappears.
3. **People who value what the co-op offers**: control, stability, and a firm that can't be sold out from under them.
Whether there are enough of them is an empirical question. [The pilot](pilot-spec.md) publishes the founders' backgrounds, including their previous earnings where the data allows, so that if the strongest founders stay away, it shows. If the portfolio fails, the first question will be "was it co-ops, or was it who applied?" [Co-ops or Their Founders?](who-founds.md) sets out how the pilot answers it: founders on both sides are measured from government records, and the verdict has to say how much their backgrounds explain. It doesn't change pass or fail. If the lock keeps strong founders away, that's part of what the lock does.
@@ -0,0 +1,108 @@
---
title: Does Sharing the Profit Mean Less Effort?
section: The Co-op
order: 21
summary: In a big co-op, your extra effort barely moves your own profit share, so the model leans on what the evidence points to (small teams, open books, team bonuses the members design, trust) and sets a fair, fast process for the rare member who won't do the work, with protections so it can't be turned on the wrong people.
---
# Does Sharing the Profit Mean Less Effort?
Here's the oldest objection to shared ownership. In a co-op of 1,000 members, if you work hard enough to add $1,000 to the profit, your own share goes up by about a dollar, less after the reserve. Everyone else's goes up by a dollar too, including anyone who didn't bother. So why work harder? Economists call it the free-rider problem, or the 1/N problem: with N members, you get 1/N of what you add.
The founding plan's answer is culture and structure: co-ops hold members to their share of the work, and groups of co-ops keep each unit small enough that people notice. This page checks that against the evidence and turns it into rules.
## What the evidence says
**Workers can tell who isn't pulling their weight, and many act on it.** A large study surveyed workers at over 300 worksites in 14 companies with group incentive pay, like profit sharing or employee stock, and added questions to a national survey. These were conventional companies, not co-ops. Most workers said they could tell when a co-worker was shirking. Many said they would talk to that person or report it, and many said they had. Acting on it was more common where workers had a real stake in the results, in smaller workplaces, and where relations with management were good. Workplaces where more workers acted also reported that co-workers worked harder and the workplace worked better ([Freeman, Kruse and Blasi, 2008](https://www.nber.org/papers/w14227)). That's a link, not proof of cause: better-run workplaces may simply produce both.
Three details matter for this model:
- **Group incentives went with other things:** teams, training, varied work, light supervision and decent base pay. On their own, they did less.
- **Size mattered, and so did trust.** The link was strongest in the smallest workplaces and weaker in the largest. But it held in larger companies where workers trusted management. The authors' reading: workers act to raise output only when they're confident they'll share in the gains.
- **A small stake can backfire.** Where the profit-sharing bonus was small, eligible workers were *less* likely than others to act against a shirker. The authors think a shared bonus makes people fear co-workers will resent them for speaking up, and a small bonus isn't worth that. In a co-op of 1,000, everyone's slice of a single person's effort is small.
**Ownership doesn't seem to hurt performance.** A review of 102 studies covering nearly 57,000 firms found employee ownership had a positive but very small link with firm performance (a correlation of 0.04), with no clear difference by firm size ([O'Boyle, Patel and Gonzalez-Mulé, 2016](https://doi.org/10.1111/1748-8583.12115), as summarized by [LEP](https://www.economicpossibility.org/sources/employee-ownership-and-firm-performance-a-meta-analysis)). That covers employee ownership in general, not co-ops specifically. In France, worker co-ops were as productive as conventional firms or more so ([Fakhfakh, Pérotin and Gago, 2012](https://ideas.repec.org/a/sae/ilrrev/v65y2012i4p847-879.html)), though that's co-ops that survived, under French law.
**What the evidence can't settle.** None of it is an experiment, none of it studied co-ops of thousands of members governed one member, one vote, and there's no direct evidence on the dismissal process below. So these rules are a starting point, and the pilot measures whether they work.
## Required, and up to each co-op
Governance is up to each co-op ([Worker Ownership](worker-ownership.md)). So this page separates two things:
- **Required** in every locked co-op's charter: the protections in the dismissal process, protections for disability, pregnancy and leave, the rules on bonuses, and the reporting.
- **Standard bylaws,** which a co-op can change by vote: how teams are organized, how often the books are shared, and who hears a dismissal first.
## Small teams, open books
People notice what their co-workers do in teams, not in firms of thousands. The standard bylaws organize work in teams small enough that everyone knows what everyone else does, and give each team its own numbers: what it produced, what it cost, how much work had to be redone.
Where a line of business gets big enough to be its own firm, it can become one, as Mondragon's co-ops did ([When Growing Means Sharing](growth.md)).
The standard bylaws share results with every member every quarter, or every month once a co-op has more than 50 workers, and offer training in reading them. The 50 is a judgment, not a finding. In a smaller co-op, most people can see most of the work themselves, and closing the books every month costs bookkeeping time a small co-op may not have. A team can still get its own numbers monthly at any size if the members want. The [federation](federation.md) supplies templates and training, so small co-ops don't have to build their own.
## Team bonuses the members design
The profit share is split equally per hour worked, and that doesn't change. But pay can include bonuses ([Who Sets the Pay](pay.md)), and a co-op can pay team bonuses tied to results the team can see and affect. The study found that the bigger the gain-sharing bonus, the more workers acted against shirking.
The required rules keep bonuses from becoming a way to reward friends or push people too hard:
- **Every bonus follows a formula the members approved in advance,** in writing, before the year starts. Managers apply it. They don't pick who gets what. That covers any bonus a manager hands out under [Who Sets the Pay](pay.md). Managers still decide raises within the bands.
- **Team bonuses, not individual ones.** The study found that bigger individual bonuses went with *less* action against shirking, perhaps because they make co-workers competitors. So a formula pays teams, and a team's bonus is split among its members by hours worked. Managers' own results pay, set by the board, is a separate formula under the same limits and is published by name.
- **The total is limited.** All bonuses together can't be more than 10% of the co-op's total pay for the year, and no one's bonus can be more than 15% of their own pay. That stops one small team, or the managers, from taking the whole pot. The study doesn't set these numbers, and it doesn't argue for a limit at all. Its finding runs the other way: bigger group bonuses went with more action against shirking. The limit is a policy choice, to keep bonuses from turning into piece rates that push people too hard, or into a way to pay friends. For scale, workers in the study who got profit sharing or gain sharing received a most recent bonus worth about 5% of their pay at the median (4.6% in the national survey, 5.7% in the companies) and 9–12% on average. But that counted profit sharing, and a member's equal profit share already plays that part here. The bonus comes on top.
- **Paid from a profit, or from the savings themselves.** A bonus tied to the co-op's results is paid only out of a profit on the audited books, and takes at most half of the year's profit before bonuses. A gain-sharing bonus tied to a team's own measured savings (less waste, fewer injuries, less redone work, against a baseline set before the year) can be paid even in a year with no profit, but only up to half of the savings and 3% of the team's pay. If the books are later restated, bonuses paid on the wrong figures are taken back from future bonuses.
- **Measure quality and safety, not just speed:** work that had to be redone, waste, injuries. Customer ratings can't be used unless they're checked every year for bias by race, gender, age and disability, because customers can rate some workers lower for the same work.
- **Measured per hour, with protected leave left out.** A teammate's parental leave, medical leave or accommodated schedule can't lower the team's numbers.
- **How work and costs are split between teams is published,** and a team can challenge its numbers.
- **Reported.** Bonuses are reported in the co-op's pay-gap figures by gender, race, age and disability.
Bonuses still stay within the approved pay bands and count toward the pay ratio. Bonuses that push pay above the going rate give the reserve its share ([Investing for the Long Run](the-long-run.md)).
Managers need a reason to deal with problems too. The board can tie part of managers' pay, within their bands and the limits above, to how their units do, and the board, not the teams they manage, reviews them.
## When a member won't do the work
Holding members to their share of the work has to mean something specific. Otherwise it means nothing, or it means removing whoever a majority dislikes. So every co-op's charter includes this process.
**Help first.** Before any warning about performance, the co-op checks for a cause it has to accommodate, like a disability, pregnancy or caregiving leave, and offers a reasonable change. Performance is judged against the accommodated schedule. Then comes a written plan to improve, with training or a move to other work if that fits. These protections apply however small the co-op is, even below the size where federal discrimination law starts.
**Then a fair, fast decision.**
- **Written charges and the evidence,** at least 10 days before the hearing.
- **A hearing** before a panel of five members drawn by lot, no more than a week before the hearing. The draw leaves out whoever raised the complaint, the person's managers, close family or partners of either side, and anyone on the person's team now or six months before the charges, so moving people between teams doesn't change who can sit. Either side can strike one name, and either side can challenge a panelist for a close tie or open hostility. Struck names are redrawn. **The co-op needs four of the five votes.**
- **In a small co-op,** there may not be five members left who aren't involved, and the rest may all be close to one side. So in a co-op of fewer than 15 members, or any co-op that can't fill the panel, the [federation](federation.md) draws the five from members of other co-ops, under the same rules. A person in a co-op of any size can ask for that outside panel.
- **Paid, private and recorded.** Panel time is paid work time. The federation pays outside panelists' time and travel from its dues. Nobody on either side can contact a panelist privately. The hearing is recorded and closed, and the panel sees only what an accommodation requires, not medical records. Interpreters and disability accommodations are provided.
- **The person can bring a co-worker or union representative.**
- **Just cause, proved by the co-op.** The co-op has to show it's more likely than not.
- **A written decision within 30 days** of the charges.
- **An outside arbitrator on request,** from a list the federation keeps, on appeal. The arbitrator works from the recording and can hear new evidence if the person had no representative or interpreter. If the dismissal is overturned, the person gets their job back with back pay.
- **Serious misconduct,** like theft or violence, allows suspension with pay while the hearing happens.
Co-ops that hire managers can let them dismiss members directly. The member can still take it to the hearing and the arbitrator, and gets their job back with back pay if it's overturned.
**Protections that can't be waived.**
- **No retaliation.** If a member is dismissed within 12 months of organizing, filing a complaint, reporting a safety problem, running for the board or leading opposition in a vote, the co-op has to prove the dismissal had nothing to do with it.
- **Quitting under pressure counts.** A member who quits within 60 days after a transfer, a cut in hours or pay, a worse schedule, losing their regular clients, or being left out of a bonus can ask for a hearing, and the quit is treated as a dismissal. That's the same test [When People Don't Stay Long](high-turnover.md#no-pushing-people-out-before-theyd-join) uses for workers on probation.
- **Probation isn't a loophole.** Workers on probation get the same hearing on shorter clocks: charges 5 days ahead, a panel of three drawn the same way (from other co-ops if need be) that must be unanimous, a decision within 15 days of the charges, and the same appeal. The co-op checks first for a cause it has to accommodate, and any plan to improve starts only once an accommodation is in place and runs at least 30 days. The membership clock keeps running: a worker whose case isn't settled when probation ends becomes a member, subject to the outcome. That makes a three-month probation hard to end early for performance. The model accepts that. The test against pushing people out only sees patterns, so one person's case needs its own protection. Dismissing them for performance still counts in that test. Only the narrow reasons listed there, like a client's safety, are excluded.
- **The savings are safe.** A dismissed member's [capital account](capital-accounts.md) is fixed on the day they leave, they get a share of that year's profit for the hours they worked, and it's paid on the normal schedule. Nothing can be taken out of it without a court or arbitrator's ruling.
**Published.** Every year the co-op reports warnings, hearings and dismissals by gender, race, age and disability, along with the results of an anonymous survey on workload and bullying.
## Where the share is small anyway
In low-margin co-ops, like home care, a full profit share is worth a couple of thousand dollars a year, on top of the pay, schedules and say in the work that members get. There, the study's warning applies: a small shared stake may not motivate anyone. So those co-ops can't count on the profit share to do the work, or on bonuses either: half of a thin profit is a small bonus, and gain sharing from a team's own savings is limited to 3% of its pay. Pay, schedules, training and a real say in how the work is done matter more.
## Measuring it
The pilot publishes value added per hour worked (what a firm's work adds, after the cost of materials and services it buys, before wages and profit are split), compared with matched conventional firms ([The Pilot, in Numbers](pilot-spec.md#what-gets-measured)). It follows each co-op over time as it grows, in size bands set in advance, and accounts for how much equipment each firm uses per hour. Spin-offs are counted with their parent, and co-ops that close stay in the results. Injuries and overtime are published alongside, so output bought with worn-out workers doesn't count as success.
If co-ops fall behind as they grow, that's evidence the 1/N problem is real, and it gets reported that way.
## What this doesn't solve
- **Big co-ops are still harder.** People hold each other to account best in small places. Teams and spin-offs help. A co-op of 10,000 will still feel more distant than one of 30.
- **Everyday pressure doesn't reach a hearing.** Co-workers who hold each other to account can also pick on people who are different. The protections, the published figures and the anonymous survey make that visible. They can't stop all of it.
- **The process takes time.** A month from charges to decision is fast for a fair process and slow for a small business with a problem. That's the trade.
- **Team bonuses can pit teams against each other.** Members choose what to reward, and some will choose badly.
- **The numbers are first drafts.** The 10% and 15% bonus limits, the four-of-five panel and the 50-worker line for monthly books are judgments, not findings. A stricter panel protects members from a hostile majority and makes it harder to remove someone who really won't do the work. The pilot's published hearings and dismissals are how to tell whether the balance is right.
- **The evidence is thin.** The best study asks workers what they'd do and what they've done, in conventional firms. The pilot is the first real test of these rules in co-ops.
+141
View File
@@ -0,0 +1,141 @@
---
title: When Growing Means Sharing
section: The Co-op
order: 29
summary: Members who split profit equally have a reason to turn down growth that earns less per worker than they already do, so the model lets co-ops grow by starting new co-ops they invest in, under rules that stop that becoming a way to push lower-paid work out, and measures whether they grow as fast as other firms.
---
# When Growing Means Sharing
A conventional owner takes on a new line of business if it earns more than the money it ties up would earn elsewhere. How many people it employs doesn't matter.
A co-op's members decide differently. After the [ramp](worker-ownership.md), they split profit equally. So a new line has to earn at least as much per worker as the co-op already does, or it lowers everyone's share. **A line that makes money can still make each member poorer, unless it's set up as a co-op of its own, which this model makes the standard path.** Economists have known about the problem since Benjamin Ward described it in 1958, and it's a real weakness.
## A worked example
A 40-member co-op has a members' profit pool, the part left after the reserve and the federation fund ([Member Capital Accounts](capital-accounts.md#where-each-years-profit-goes)), of $800,000 a year. That's $20,000 per member.
It has a chance to open a new line. The line needs 20 more workers, pays them normal salaries, and adds $200,000 a year to the pool: $10,000 per new worker. Suppose that's comfortably more than the money it ties up would earn anywhere else. A conventional owner would take it.
For the members, it depends on the ramp. New members start at a fifth of a full share and reach a full share after five years. The founding plan allows up to ten.
| Year | Each full share, 5-year ramp | Each full share, 10-year ramp |
| --- | --- | --- |
| No new line | $20,000 | $20,000 |
| 1 | $22,700 | $23,800 |
| 3 | $19,200 | $21,700 |
| 5 | $16,700 | $20,000 |
| 10 and after | $16,700 | $16,700 |
| **Total over 10 years** | **$181,000** (vs. $200,000) | **$199,000** (vs. $200,000) |
(The 10-year ramp here starts at a tenth of a share and adds a tenth each year.)
**Tax law changes this table.** It assumes the part of a share new members haven't reached yet goes to the other members. Under the model's default tax rules, it goes to the co-op's reserve instead ([How a Locked Co-op Is Taxed](taxes.md#the-tenure-ramp)). Then each full share drops to about $16,700 from the first year, and in year 1 the reserve gets about $267,000 extra with the five-year ramp. The table holds only for a co-op that pays the difference as wages for years of service, at the cost of payroll tax. Either way, that makes the spin-off route below more important.
A member near retirement comes out ahead either way. A member with twenty years ahead loses about $50,000 over that time with a five-year ramp, and about $35,000 with a ten-year one. Counting money later as worth less than money now (at 7% a year), those losses are about $23,000 and $10,000. The gains come first and the losses later, so members who expect to leave soon and members who expect to stay long will vote differently. The ramp softens the problem. It doesn't remove it. In the end, **the new line has to earn the co-op's average, not just a profit, before the members come out even.**
**Where it bites, and where it doesn't.** The gap only matters when profit per member is high. In home care, a full share is worth a couple of thousand dollars a year ([When People Don't Stay Long](high-turnover.md#the-ramp-when-people-leave-early)), so a new line that earns less costs each member a few hundred dollars at most. In a firm making tens of thousands of dollars per worker, the kind described in [What If Conservice Had Been a Co-op?](conservice.md), it's real money. And the most profitable co-ops are the ones whose growth matters most.
## What the evidence says
**Co-ops are steadier, and slower to hire.** A study of Italian firms from 1982 to 1994, in the market economy with the most worker co-ops, found co-ops paid about 14% less on average. Their pay moved more with good and bad times, and their employment moved less, in both directions. When business picked up, co-ops added fewer workers than conventional firms. The co-ops in that data were larger on average than conventional firms, not smaller, though that comparison doesn't account for which industries they're in ([Pencavel, Pistaferri and Schivardi, 2006](https://docs.iza.org/dp2188.pdf)). A study of every co-op and conventional firm in Uruguay's social security records from 1996 to 2005 found the same pattern: co-op pay responds more to shocks, and employment less ([Burdín and Dean, 2009](https://doi.org/10.1016/j.jce.2009.08.001), as summarized by [Pencavel, 2012](https://docs.iza.org/dp6932.pdf)).
In France, a comparison across many industries found worker co-ops grew as fast as conventional firms or faster ([Fakhfakh, Pérotin and Gago, 2012](https://ideas.repec.org/a/sae/ilrrev/v65y2012i4p847-879.html)).
Ward's starkest prediction, that co-ops would *cut* jobs when prices rise, doesn't show up. What shows up is slower hiring and steadier jobs. The steadiness that protects members in bad years is the same thing that makes them slow to hire in good ones.
One caution: co-ops in Italy and Uruguay can hire non-members. Co-ops under this model mostly can't (below). So that evidence probably understates the problem here.
**When hiring members is costly, co-ops hire non-members instead.** The Olympia Veneer Company, the first of the Pacific Northwest plywood co-ops, started in 1921 with 125 worker-members. As founders left, the co-op bought back their shares and hired ordinary employees in their place. By 1952 fewer than 50 members were left, and in 1954 they sold the company for a handsome return. Mondragon's industrial co-ops saw non-members reach 29% of their workforce in 2000, before new rules brought it down to 12% by 2008. Counting the ordinary companies Mondragon owns in Spain and abroad, non-members went from 10% of its workforce in 1990 to 70% in 2007 ([Pencavel, 2012](https://docs.iza.org/dp6932.pdf), citing Arando and others, 2011).
That route is closed here. [The lock](the-lock.md) requires members to work at least three-quarters of the hours, and any company a co-op controls is locked too. That stops a co-op from turning into a small club of owners with employees under them. But it also means a co-op can't get around dilution by hiring outsiders. So dilution bites *harder* here than in co-ops that allow hired labor. The model accepts that, because the alternative is the road the plywood co-ops took out of being co-ops.
## The model's answers
### 1. Grow by starting new co-ops
Mondragon found a way through early. When its first factory, Ulgor, grew new lines of business, it turned them into co-ops of their own. Its electronics division became a new co-op, Fagor Electrónica, in 1965, "continuing with the plans for creating spin-offs from Ulgor" ([Mondragon, *The Mondragón Co-operative Experience, 1956–2012*](https://laboress-afrique.org/boostsess/public/uploads/pdfdocuments/files/mondragon_1956_2012.pdf)).
But spin-offs weren't the whole system. Ulgor and its spin-offs formed a group, Ularco, that began partly pooling its co-ops' profits in the late 1960s and pooled them completely from 1976. When Mondragon reorganized in 1992, it planned to pool between 15% and 40% of profits across each of its new sectors. Workers moved between co-ops when one shrank. From 1979 to 1983, when Ulgor had to cut 1,300 of its 3,500 jobs, 145 members split off to form a new co-op and others moved within the group, and every move was voluntary ([Morris, *The Mondragón System*, 1992](https://www.ilsr.org/wp-content/uploads/files/images/mondragon.pdf)).
The model makes spin-offs the standard path for a new line that earns less per worker than the parent:
- **The new line becomes its own locked co-op,** with its own members, its own profit split, and a [lock share](the-lock.md#how-the-lock-is-made-to-hold) held by an independent lock-holder from the day it's founded.
- **The parent invests, but doesn't control.** It can fund the new co-op from its reserve, on the terms any investor gets ([Capital, After](capital.md)): repayment plus a share of profits, for at most 15 years, with no vote. If the new co-op borrows from the bank, the parent's claim ranks behind the bank and needs the bank's approval, like any other.
- **Reserve money stays reserve money.** When the new co-op repays the money, it goes back into the parent's reserve. Only the profit share counts as profit, and the reserve takes its cut of that too ([Investing for the Long Run](the-long-run.md)). No single spin-off can take more than 15% of the parent's reserve. Up to a quarter is allowed only by a majority of all members in a secret ballot, the same bar [the lock](the-lock.md#when-a-co-op-owns-another-company) sets for buying a company. "Take" counts everything the parent has at stake: money put in and not yet repaid, guarantees of the spin-off's loans, assets sold to it on credit, and rent or bills left unpaid for more than 90 days. Spin-offs in the same business, and spin-offs of spin-offs, count as one. A stake written off in the last five years still counts, so a parent can't lose 15% and then start again. The test uses the reserve at the start of the year, when new money goes in. A reserve that shrinks later doesn't force a sale, but no new money goes in until it's back under the limit.
Federal law limits how much a national bank may lend one borrower without collateral to 15% of the bank's capital ([12 U.S.C. 84](https://www.law.cornell.edu/uscode/text/12/84)). This limit is stricter, because the reserve is only part of a co-op's own capital; members' accounts are the rest. It needs to be. A parent's stake has no collateral, it ranks behind the spin-off's lenders, and new businesses close a lot: only about 35% of American business locations opened in 2013 were still open ten years later ([Bureau of Labor Statistics](https://www.bls.gov/bdm/us_age_naics_00_table7.txt)). And a failed stake is a loss like any other, shared between the reserve and members' own accounts, with members paying most of it ([Investing for the Long Run](the-long-run.md#spending-it-badly)).
All of a parent's stakes in spin-offs together stay under half of its reserve, and under a third of its assets, not counting cash and government bonds. Above 40%, federal law can treat a company that holds investments as an investment fund, with its own heavy rules ([15 U.S.C. 80a-3(a)(1)(C)](https://www.law.cornell.edu/uscode/text/15/80a-3)). A co-op that mainly runs its own business is probably outside that law anyway, but the cap keeps it from having to argue the point.
- **The spin-off is still a co-op.** Its own workers become members, and it's in the same federation as the parent, so a failure in one doesn't leave the other's workers with nothing ([The Federation](federation.md)).
In the example, the parent's members keep their $20,000 and earn a return on what they put in. The 20 new workers build full shares of their own co-op's profit instead of partial shares of someone else's. Without the spin-off, the members would most likely have turned the line down, and the jobs wouldn't exist.
This matters most for exactly the case the founding plan worried about: **expansion that takes a lot of capital, into lower-margin work.** The parent's reserve can pay for it without anyone's share shrinking.
**Yes, this makes richer and poorer co-ops.** The spin-off's members earn a smaller profit share than the parent's. That's the honest cost. The model's line is that inside one firm, where people share a vote and a set of books, shares are equal. Between firms, they aren't, as they aren't between any two companies. What stops that becoming a way to push lower-paid workers out is the next set of rules.
### 2. Rules so spin-offs can't be abused
- **New lines only, unless the workers choose to go.** A co-op can't spin off work its members already do unless a majority of the workers whose jobs would move vote for it. Anyone who doesn't want to move keeps their place in the parent.
- **A spin-off that mostly works for the parent gets the parent's terms.** The worry is a parent moving its own work into a captive spin-off whose members get a smaller share. So if a spin-off makes more than 40% of its sales to the parent in any two of three years, counting sales through a middleman, through the parent's other spin-offs, or through firms its members own, the parent has to close the gap. The spin-off's workers choose, by secret ballot: membership in the parent with the tenure they've built, or a profit pool with the parent (below) that makes shares per member equal. The test is stricter than the one [the lock](the-lock.md#when-a-co-op-owns-another-company) uses for an outside supplier (most of its output, two years running), because the parent created this company. Prices between the two are still checked every year, as below.
- **Deals between parent and spin-off are insider deals.** Prices, fees, rent and contracts between them must match what outsiders would pay, and they're checked every year, like any [insider deal](pilot-safeguards.md#the-hidden-boss). The lock's limit on exclusive contracts applies: no contract gives one party a claim on most of what the other produces for longer than five years ([The Lock](the-lock.md#closing-the-back-doors)).
- **Assets move at an independent appraisal.** Equipment, customers or buildings moved to the spin-off count as a sale of part of the business, which goes through the lock-holder. The parent keeps any debts and obligations from before the split. So a spin-off can't be used to park a failing line or old liabilities.
- **Those who move don't vote on the money.** Parent members who plan to join the spin-off can't vote on the parent's investment in it. They keep their tenure on the ramp only if the spin-off's founding papers agree to it, so senior members can't move over and dilute the new members in their turn.
- **A second opinion on big investments.** Before a parent puts more than a tenth of its reserve into one spin-off, counting everything it put in over the last three years, the federation gives a written opinion on the plan to the parent's members. It's advice, not a veto. The investment is the members' call. Bigger commitments also need the independent adviser's opinion that [every large commitment](the-long-run.md#spending-it-badly) needs.
### 3. Sharing across the group, if the co-ops choose
Parent and spin-off can agree to pool part of their profits and split it per member across both, as Mondragon's groups did. That narrows the gap between them. It also brings back part of the dilution the spin-off avoided, which is why it's up to them. A pool of 15% to 40%, the range Mondragon settled on, is a reasonable place to start. The federation offers a standard agreement.
### 4. Where a line can't be split off
Some growth can't be spun off. More customers for the same service, or a cheaper product sold by the same team, can't easily become a separate company. There, the dilution stays. What softens it:
- **The ramp.** A co-op can choose the long end, ten years. That comes at a cost to newer members, who are more often younger or outsiders, and to workers who don't stay long ([When People Don't Stay Long](high-turnover.md#the-ramp-when-people-leave-early)).
- **The founding share is a fixed slice.** Founders' extra reward is a set share of the pool, not extra weight per head, so founders gain nothing by keeping the co-op small once it's past its first three years ([The Founder Problem](founder-problem.md)).
- **Growth that earns the average is free.** Adding workers to work that's as profitable as the rest doesn't dilute anyone. The problem is only lower-margin growth.
- **Someone else can take it.** A line the members turn down can be offered to the federation, or to a group of workers, to start as a new co-op.
### 5. Measuring it
The pilot already publishes how many jobs its co-ops create ([The Pilot, in Numbers](pilot-spec.md#what-gets-measured)). It also publishes, with the comparisons filed in advance:
- **Job growth compared with matched conventional firms,** in full-time-equivalent jobs, counted both with and without spin-offs, and broken out by thirds of value added per worker (a way to compare how productive firms are that doesn't depend on how a co-op splits pay and profit). The gap should show up most in the most productive co-ops. Growth is measured from each co-op's second full year to its tenth, the same way for its matched firms, and a firm that closes counts as losing all its jobs, so co-ops that survive by not growing aren't flattered or punished by who's left. Value added isn't reported for the matched conventional firms, so the thirds come from each co-op's own value added per worker in its second full year, before growth is measured, and each co-op is compared only with its own matched firms. Productivity is also tested as a sliding scale across all co-ops, not just in thirds. Young firms' job growth varies enormously. Among American firms under five years old that stayed open, the fastest-growing tenth grew about 110 percentage points a year faster than the slowest tenth, and a few very fast growers pull the average up ([Decker and others, 2016](https://www.nber.org/papers/w21776)). On rough figures, about 130 co-ops in the top third against about five matched firms each, the smallest gap the comparison could reliably find is something like 12 to 20 percentage points a year, depending on how many close and how the reviewers handle the outliers. So **the prediction that the gap shows up most in the most productive co-ops probably can't be tested at this scale.** The reviewers file the time span, the handling of outliers and the power calculation before the first loan. A finding of no gap doesn't mean co-ops grow as fast.
- **Expansions voted down.** Each co-op reports every major expansion proposal that reached its board or a members' vote, and what happened to it. "Major" means a new line of business, a new location, a spin-off, or anything expected to add at least a tenth to the co-op's hours or to spend at least a tenth of its reserve. Related proposals within a year are added together, so splitting one plan into small pieces doesn't hide it. Any member can put a proposal on the list. Lines offered to the federation or to other workers after the members turned them down are counted too. The yearly audit checks the list against actual hiring and spending. A proposal nobody writes down still can't be counted, so this undercounts.
- **Spin-offs:** how many, how they do, and the gap in pay and profit share per member between each parent and its spin-offs.
- **Who gets hired.** Applicants and hires by gender and race, compared with the matched firms, since members who share profit have a reason to hire few people, and people like themselves.
- **How hires heard about the job.** The share of hires who came through a member's referral rather than a public posting, and how long referred and other hires stay. Referred hires often stay longer, so referrals aren't bad in themselves. But hiring through friends and family is also the quietest way for a co-op to keep looking like its founders, and applicant counts can't see it.
- **Who leaves during probation.** People let go or pushed out before they could join, by gender and race, pooled across co-ops. The push-out check ([No Pushing People Out](high-turnover.md#no-pushing-people-out-before-theyd-join)) looks at one co-op at a time and can't see a pattern that small.
- **Compared with the local workforce.** Hires are compared with people doing the same work in the same county, from Census survey data, as well as with the matched firms.
None of this sets targets. It's measured because the incentive is predictable, not because co-ops are assumed to discriminate.
**Testing hiring directly.** Counting applicants can't show whether equally qualified people are treated alike. The standard check sends fake job applications that are identical except for a name suggesting the applicant's race or sex, and records who gets called back. The largest such study sent over 83,000 applications to entry-level jobs at 108 of the biggest American employers. About 24% got a call back within 30 days. Applicants with distinctively Black names got about 2.1 percentage points fewer calls, and the gap varied a lot between companies. Telling one company apart from the rest took up to 1,000 applications per company ([Kline, Rose and Walters](https://www.nber.org/papers/w29053)). So:
- **Co-ops are tested as a group, never one by one.** A small co-op fills a few jobs a year, far too few to judge on its own. The pilot's outside reviewers send applications to entry-level openings at funded co-ops at random times between years three and nine. The dates aren't announced. Each opening gets one pair of applications, one with a name suggesting a Black applicant and one a white applicant, because more would stand out at a six-person co-op. Jobs that need a license are skipped unless both applications can honestly show the same one.
- **One main measure, filed in advance.** The difference in calls back within 30 days between the Black and white names, pooled over all the testing. It's reported on its own terms, against zero, and next to the published results for big employers. Sex, Hispanic names and other comparisons are reported too, corrected for testing many things at once.
- **What it can find.** Before filing, the reviewers count how many entry-level openings co-ops actually post, from the pilot's own payroll records, and state the smallest gap the test could detect, allowing for the fact that openings at the same co-op aren't independent. As a rough guide, if co-ops posted about 1,500 openings, the smallest gap it could reliably detect would be somewhere around 5 to 6 points, more than twice the big-employer average. If openings fall short, the result is "not settled." Either way, **a test that finds nothing doesn't show co-ops hire fairly.** It can only catch co-ops being much worse than ordinary employers.
- **No unwilling firms.** Co-ops agree to the testing when they borrow. Conventional employers never agreed to anything, so they aren't sent fake applications, even though that would make a cleaner comparison. The co-ops' result stands next to other studies' published results instead. Before any testing, the ethics board that approves [the support lottery](pilot-spec.md#the-support-lottery) signs off, and state laws on testers are checked. Fake applications are withdrawn as soon as a co-op responds. Afterward, a sample of co-ops is asked whether they spotted the test, and the rate is published.
- **Every opening is posted, with exceptions.** As a loan condition, co-ops post every opening publicly for at least a week, with written criteria for the job, before filling it. Payroll start dates are checked against posting dates, so a posting that closes after the new hire started doesn't count. Exempt, but counted separately: recalling laid-off members, rehiring someone within three years, union hiring halls, and aides a home care client chooses.
- **Discrimination is a loan breach, whatever the co-op's size.** Federal job-discrimination law covers only employers with 15 or more workers ([42 U.S.C. § 2000e(b)](https://www.law.cornell.edu/uscode/text/42/2000e)), and most co-ops are smaller. Every borrower agrees not to discriminate in hiring, pay or membership. Complaints go to the civil-rights agencies where they can act, and to the bank as a possible breach where they can't.
- **What a bad result does.** Nothing happens to any single co-op on the test alone, because the test can't single one out. If co-ops as a group show a real gap, scored interviews and outreach beyond members' own networks become a condition of every new loan, and the test is repeated.
Spin-offs can borrow from the bank. Because they start with an established co-op behind them, they're reported separately and don't count in the pilot's main test, the same way the pilot handles businesses that aren't really new ([Guarding the Pilot](pilot-safeguards.md#relabeling-an-old-business-as-new)). That keeps them from flattering the survival numbers, and keeps a struggling low-margin spin-off from counting against the test.
If the most productive co-ops grow much more slowly than similar conventional firms, that's evidence the problem is real where it matters most, and it gets reported that way.
## What wasn't done
- **Profit shares by division.** Paying each member partly from their own division's profit would remove most of the problem inside one firm. But the founding plan says that after the ramp, everyone's share is identical. Spin-offs do create differences, but between separate firms with separate votes, not between people who govern the same firm together.
- **Profit shares in proportion to pay.** Splitting profit by pay instead of equally would make lower-paid new hires cost the others less. That also breaks the equal share.
- **More room for non-members.** It would make growth easier, and it's the road the plywood co-ops took out of being co-ops.
## What this doesn't solve
- **Not every line can be spun off.** Where growth has to happen inside one firm, members still have a reason to turn down work that earns less than their average. Reporting the expansions they vote down makes that visible. It doesn't stop it.
- **Spin-offs cost something.** Separate books, a separate board and separate legal costs. For a small line, that may be more than it's worth. And splitting a line off a firm that shares its customers and managers can make both work less well.
- **Reserve money can be wasted.** Money that can't be paid out is tempting to spend. Fagor, Mondragon's appliance maker, overexpanded and went bankrupt in 2013 ([Investing for the Long Run](the-long-run.md#whats-still-open)). The caps on spin-off stakes and the federation's opinion are limits, not a test of whether an investment is wise.
- **The parent profits from people it doesn't work with.** For up to 15 years, the parent collects a share of the new co-op's profit. It's finite, it carries no vote, and dealings between them are checked at outside prices. That's the model's line between renting capital and owning people's work ([Capital, After](capital.md)). Some will say it's still the wrong side of that line.
- **Richer and poorer co-ops.** Spin-offs will often be lower-paid than their parents, and lower-margin work is disproportionately done by women and workers of color. Voluntary pooling and published gaps are the answer offered. Neither closes the gap.
- **Steady employment cuts both ways.** Co-ops keep people on in bad years and hire slowly in good ones. Where that means fewer jobs than the business could support, the people who don't get hired pay for it, and they don't get a vote.
@@ -0,0 +1,138 @@
---
title: When People Don't Stay Long
section: The Co-op
order: 28
summary: In jobs like home care, most workers leave within a year or two, so the model makes membership automatic after a short probation, keeps the buy-in small and out of people's paychecks, and guards against co-ops that push people out before they'd join.
---
# When People Don't Stay Long
This model's co-ops are run by their members, and it takes membership seriously. After a trial period (probation) of at most 12 months, workers become members, and members must do at least three-quarters of the work hours ([The Lock](the-lock.md)). That rule stops a co-op from turning into a small club of owners employing everyone else.
But in some of the jobs where co-ops matter most, people don't stay long. In those jobs, the rule as first written could fail a co-op that was doing everything right.
## The problem
**Home care loses most of its workers every year.** Home care aides help older and disabled people at home: bathing, meals, medication. An industry benchmarking survey published in 2024 put home care worker turnover at about 79% a year ([Home Health Care News, 2024](https://homehealthcarenews.com/2024/07/home-cares-industry-wide-turnover-rate-reaches-nearly-80/)). Turnover here means the number of people who left in a year, divided by the average number of workers.
**Co-ops do better, but still lose a lot of people.** In 2017 there were 14 home care co-ops in nine states, employing about 2,400 caregivers. One account puts their turnover at about 38%, against 82% for the industry at its peak ([OnLabor, 2022](https://onlabor.org/worker-retention-in-homecare-could-cooperatives-be-the-solution/)). That's a small, uncontrolled comparison, but it points one way.
**With that much turnover, many workers never get past a 12-month probation.** If people leave at a steady rate through the year, here's roughly what share of the workforce at any moment has been there long enough to join:
| Turnover per year | Past 3 months | Past 6 months | Past 12 months |
| --- | --- | --- | --- |
| 20% | 95% | 90% | 82% |
| 38% (home care co-ops) | 91% | 83% | 68% |
| 65% | 85% | 72% | 52% |
| 80% (home care industry) | 82% | 67% | 45% |
This is a simple model, and it counts people, while the rule counts hours. Both of those make the real picture somewhat better: many people leave in their first few weeks, and people who stay tend to work more hours. But the point holds. **With a 12-month probation, a home care co-op can fail the three-quarters rule even if every eligible worker joins.**
**The biggest real example shows the other half of the problem.** Cooperative Home Care Associates in the Bronx was started in 1985 by 12 home health aides and is one of the largest worker co-ops in the country. It's also unionized, with 1199SEIU ([CHCA](https://www.chcany.org/about)). It offers ownership after just three months. But workers have to choose to buy a $1,000 share, paid over time from their paychecks. In 2005, about 70% of its aides were owners, and 95% to 97% of those owners worked full time, so by hours it may well have met this model's rule. At a smaller sister co-op in Philadelphia, about 40% of aides were owners ([PHI, 2005](https://www.phinational.org/wp-content/uploads/legacy/clearinghouse/homecarecooperatives_workerownership.pdf)). By 2021, one account put CHCA at over 1,000 owners among 2,600 employees ([OnLabor](https://onlabor.org/worker-retention-in-homecare-could-cooperatives-be-the-solution/)). That count may include office staff, and it's by head, not by hours. But it suggests that when membership has to be chosen and paid for, many workers don't take it up.
To be fair to CHCA: it has stayed a real, worker-governed co-op for about 40 years with many workers who weren't owners. A union contract covering everyone helps explain why. This model still sets a membership rule, because not every co-op will have CHCA's history or its union.
## Two bad answers
- **Loosen the three-quarters rule.** That lets a co-op run on non-members again, which is exactly the slide the rule exists to stop. In a high-turnover business, "most of the workers aren't members" can easily become permanent.
- **Exempt care work.** That would exempt the co-ops whose workers are least able to protect themselves.
## The answer: let people in sooner, by default, at no cost up front
### Membership is automatic
When probation ends, a worker becomes a member **unless they say no**. They don't have to apply, and nobody has to approve them.
This matters more than it sounds. When one large company started signing new workers up for its retirement plan automatically, 86% of new hires took part, compared with 37% of workers hired just before, at the same length of time on the job. The biggest gains were among the lowest-paid, whose participation went from 13% to 80% ([Madrian and Shea, 2000](https://www.nber.org/papers/w7682)). People mostly stay with whatever happens by default. So the default should be membership.
A retirement plan and a co-op aren't the same, and the same study found that people signed up by default mostly stayed passive about their choices. A member signed up by default may not act like an owner at first. The model's bet is that having the vote is the precondition for using it.
- **Saying no is easy and goes through someone independent.** A worker can opt out by phone, in person or in writing, in their own language, with the co-op's [lock-holder](the-lock.md#how-the-lock-is-made-to-hold), the independent body that guards each co-op's lock, rather than with their manager. So managers can't quietly talk people out of joining. Anyone who opts out can change their mind within 30 days, or join later at any time.
- **Members and non-members get the same pay and schedules.** A co-op can't pay non-members more, or give them better shifts, to keep them from joining.
- **People who freely opt out don't sink the co-op.** Hours worked by people who opted out through the lock-holder count toward the three-quarters rule, up to one-tenth of all hours. Beyond that, they count as non-member hours. So a co-op isn't punished for a few workers' free choices, but it can't run on them either.
### Joining costs nothing up front
Existing home care co-ops charge refundable fees of $25 to $1,000, often taken from paychecks a little at a time (PHI, 2005). That's a real cost for someone earning an aide's wage, and in many states a deduction from pay needs the worker's written permission.
So in this model, **the buy-in comes out of the member's own profit shares, not their paycheck.** It's capped at $1,000. Until it's covered, part of each profit share goes toward it instead of being paid out in cash, though never the cash a member needs to pay tax on their share. It's held apart from their [capital account](capital-accounts.md), at its face value. It earns no interest and isn't charged with the co-op's yearly losses, and it's paid back in full within a year after they leave. Nobody pays it from their paycheck, so nobody puts cash into the co-op to join ([Member Capital Accounts](capital-accounts.md#are-accounts-securities)).
### A short probation
Probation still can't be longer than 12 months. But a co-op can make it shorter, and the model's standard bylaws use three months, as Cooperative Home Care Associates does. The table shows why a high-turnover co-op will want that: at 38% turnover, a three-month probation puts about 91% of the workforce past it.
**Why letting people in early is affordable.** It's the [profit share ramp](worker-ownership.md). A new member starts with a small slice of the profit that grows over the years. So a new member takes only a small slice of the profit at first. The rest of what their hours earn goes to the co-op's reserve, not to the other members, because of how federal tax law treats profit shares ([How a Locked Co-op Is Taxed](taxes.md#the-tenure-ramp)). What the new member gets right away is the vote. Under this model, that doesn't wait. Workers on probation get a profit share too, in cash, at the rate of a member just starting out.
### No pushing people out before they'd join
If membership comes at three months, a co-op that wanted to stay a small club could let people go at two and a half, or cut their hours until they quit. So:
- **Every hour counts, whoever signs the paycheck.** Hours worked on the co-op's business by staffing-agency workers, contractors or a related company's staff count as non-member hours. A co-op can't get around the rule by putting its workers on someone else's payroll.
- **Probation counts across rehiring.** Time on probation adds up across every stint at the same co-op within three years. Letting someone go and rehiring them doesn't restart the clock.
- **Pushing people out counts as letting them go.** If someone on probation quits within 60 days after their hours were cut by a quarter or more, or after they lost their regular clients, it counts as the co-op's decision, not theirs.
- **The test looks at results.** Each year the co-op reports how many people it hired, how many it let go (counted as above) before they could join, and why. The lock-holder compares that with the same co-op's record after people become members, and with similar co-ops. It looks only once there are enough cases to mean something, at least five over three years. Dismissals for a documented reason, like a client's safety or a failed background check, don't count. A worker can also complain directly to the lock-holder.
- **A finding has to be earned.** Before a pattern counts against the co-op, the co-op sees the evidence and can answer it. A confirmed pattern counts as failing the membership rule for that year.
### A start-up period
A new co-op that's hiring fast will always have many people on probation. So the rule phases in. There's no hours test in the first year, members must do at least half the hours in the second, and three-quarters from the third on. Automatic membership and the 12-month limit apply from day one.
### Unions stay
Becoming a member doesn't end a union contract. A union can keep representing workers, members and non-members alike, as 1199SEIU does at CHCA. Federal labor law may not protect members' own organizing, though, so the charter does ([Owners Are Still Workers](labor-law.md#union-rights-the-biggest-gap)). Members are also paid for time spent on the co-op's committees and board, as CHCA and its sister co-op already do (PHI, 2005), so taking part in governance doesn't cost an aide a shift.
## The ramp, when people leave early
The founding plan sets the profit share ramp at five to ten years. In a job where many people stay two or three, most will leave before they ever reach a full share. At 38% turnover with a three-month probation and a five-year ramp, only about one worker in seven has a full share at any moment. That's a real cost of the ramp, and it's worth saying plainly.
Some things make it less harsh than it sounds:
- **The vote doesn't ramp.** A member who stays one year has the same vote as one who stays thirty.
- **Time on the ramp adds up.** Someone who leaves and comes back within a few years picks up where they left off, and protected leave, like parental or medical leave, counts as time worked ([Worker Ownership](worker-ownership.md)). That matters in care work, where many workers, most of them women, step out for a while to care for their own families.
- **Whatever they built goes with them.** A member's account keeps paying out on its normal schedule after they leave, and small balances are paid all at once ([Member Capital Accounts](capital-accounts.md#how-its-paid-out)). But where most payouts would go to people who've left, pension law makes accounts risky, so co-ops with turnover this high almost always pay the members' share all in cash instead. Everyone is still signed up for an ordinary retirement plan outside the business, with the co-op adding 3% of wages and a federal match for low earners from 2027 ([Member Capital Accounts](capital-accounts.md#saving-for-retirement)).
- **In care work, the profit is small anyway.** At home care's thin margins, a full share is worth a couple of thousand dollars a year, and a typical member's is less ([Member Capital Accounts](capital-accounts.md)). For these workers, most of what matters is pay, hours and a say in how the work is done.
A co-op can choose the short end of the ramp. This page doesn't shorten it further, because the founding plan sets that range on purpose. A longer ramp was meant to let a co-op hire without every new member cutting into everyone else's share right away. Under tax law, it only does that if the co-op pays the difference as wages for years of service ([How a Locked Co-op Is Taxed](taxes.md#the-tenure-ramp)). Whether low-margin, high-turnover co-ops should be allowed a much shorter ramp is an open question ([Open Problems](open-problems.md)).
## When Medicaid decides how much goes to pay
A federal rule adopted in 2024 says that at least 80% of what Medicaid pays an agency for personal care, home health aide and homemaker services has to go to the direct care workers who give it ([42 CFR 441.302(k)](https://www.ecfr.gov/current/title-42/section-441.302)). It starts on July 9, 2030, or with the first managed care contract year after that where a state pays through private plans.
- **Who counts as a direct care worker.** Aides and personal care attendants, but also the nurses who give care and the staff who supervise it clinically.
- **What counts as their pay.** Wages, benefits like health insurance, paid leave and retirement, and the employer's share of payroll taxes. Training, travel and protective equipment come off the top before the 80% is measured.
- **What it covers.** Only these services paid under Medicaid's home and community-based waivers and three similar options. It doesn't reach Medicare, private-pay clients, or Medicaid's ordinary home health benefit, so how much it touches a co-op depends on who pays for its work. Care where the person receiving it sets the worker's rate isn't counted either.
- **Exceptions.** A state can set a lower level for small agencies that meet criteria it sets, and exempt some agencies facing hardship, after public notice.
**It may not survive.** As of September 2026 the rule is in the federal regulations unchanged. But the home care industry has fought it since it was proposed, warning it would close agencies ([Home Health Care News, 2024](https://homehealthcarenews.com/2024/04/home-cares-industry-slams-finalized-80-20-rule-warns-agency-closures-are-coming/)), and it could be changed or dropped before 2030. The model neither needs it nor depends on its failing.
**What it does and doesn't do.** It sets a floor on the share of the rate that goes to care workers. It doesn't set a floor on pay: 80% of a low rate is still low pay, and an agency can meet it by spending more on health insurance with no raise. If rates don't rise, the likely costs are agencies leaving Medicaid, fewer hours for clients, and less money for scheduling and supervision. Rules like this can raise pay and can also cost access. Which one wins is an empirical question, and the model takes no side on it.
For a co-op, it means conventional agencies now have to do part of what a co-op promises. A co-op's case in Medicaid home care then rests less on pay and more on steadier hours, a say in the work, and keeping people longer, which is what [the pilot](pilot-spec.md) has to show.
### How a co-op meets it
- **Wages, benefits and payroll taxes alone reach 80%.** The profit share and account credits don't count. The model's position is that the share is an owner's share, not pay ([Owners Are Still Workers](labor-law.md#is-the-profit-share-pay)), and a co-op can't call it pay to Medicaid and not pay to the tax office. Leaving it out also stops a co-op from holding hourly pay down and making it up with a year-end share that may not come.
- **Any shortfall is paid out as wages.** The shared payroll service works out the ratio every quarter and shows it to members. If the year ends below 80%, for any reason, the difference is paid to the direct care workers who worked that year, as an hourly wage supplement, before any profit is shared. That's plain wages, so it counts without anyone's ruling. It runs in the same payroll pass that already recalculates overtime ([Owners Are Still Workers](labor-law.md#overtime-and-the-profit-share)).
- **Job titles are checked.** Which roles count as direct care follows the rule's list and state guidance, and the co-op's auditor checks it each year, so an office job can't be relabeled as supervision.
- **The rest has to fit in the 20%.** Office staff, insurance, rent, the bank's loan payments, the reserve and any profit share all come out of what's left. In Medicaid home care that means profit shares and account balances near zero, and a reserve that grows slowly. So the bank lends to a co-op doing this work only if loan payments fit inside the 20% with no profit at all, and sets its grace periods to match.
- **Shared services are priced at cost and published.** Fees the co-op pays the federation or the bank are listed in its yearly report, at cost, so the federation can't become a way to move money out the way related-party fees do at conventional agencies.
- **No hardship exemptions.** A co-op backed by a public bank doesn't take a hardship slot from a struggling independent agency. It can use a state's small-agency level only if it meets the state's criteria like anyone else, and only with a written plan to reach 80% within three years.
- **Where the rates are set.** The same 2024 rules require each state to have an advisory group on these rates that includes direct care workers, and to publish its advice ([42 CFR 447.203(b)(6)](https://www.ecfr.gov/current/title-42/section-447.203)). Rates are the real limit on pay, so the federation takes part in those groups, and in the public comment where states set small-agency levels and exemptions, alongside unions.
### Keeping the pilot fair
Co-ops are compared with conventional firms in the same industry, region and start year ([The Pilot](pilot-spec.md#who-co-ops-are-compared-against)), but that doesn't make them equally exposed to this rule. So for co-ops and comparison firms alike, the pilot records the share of revenue from covered Medicaid services, whether the firm used a small-agency level or an exemption, its reported ratio where the state publishes it, fees paid to related companies, and whether the state bargains with a care workers' union.
## What this doesn't solve
- **Ownership doesn't fix the pay.** Home care wages are low largely because much of the work is paid for by public programs like Medicaid, at rates the agencies don't set. A co-op can't pay more than it's paid. It can offer steadier hours, training and a say, and co-ops seem to keep people longer. But the pay problem needs better public rates, rules that send more of those rates to workers, like [the 80% rule](#when-medicaid-decides-how-much-goes-to-pay), which can raise pay but may also cost access, and unions bargaining for them, including with states and through wage boards like [Minnesota's](essential-work.md), along with [the floor](the-floor.md).
- **The numbers are rough.** Turnover varies a lot between agencies, the home care industry figure comes from a survey of agencies that chose to take part, and the co-ops' 38% comes from a small count. The pilot will measure this directly and report [results by industry](pilot-spec.md#what-gets-measured).
- **Automatic membership hasn't been tried at a large care co-op.** It should raise membership a lot. How much, and whether default members take part in governance, is unknown.
- **A short probation means less time to judge fit.** A co-op admits people it has known for three months, and they vote. That's the trade-off the model accepts. The ramp limits what a poor fit costs in money. It doesn't limit their vote.
- **The stable minority can still run things.** Office staff and coordinators often stay while aides come and go. One member, one vote and paid meeting time help. They don't guarantee aides a real voice.
- **Some legal questions have answers now, and some don't.**
- **Taxpayer numbers.** An individual taxpayer number from the IRS doesn't "authorize you to work legally in the U.S." ([IRS](https://www.irs.gov/tin/itin/individual-taxpayer-identification-number-itin)). Members have to be employees verified as allowed to work, so workers who have only that number can't be members. That's a gap in the model, not a fix ([How a Locked Co-op Is Taxed](taxes.md#what-this-doesnt-solve)).
- **Work permits that lapse.** Many care workers work on permits that have to be renewed or can be ended, such as Temporary Protected Status. A member whose permit lapses has to come off the payroll. They keep their account, which pays out on its normal schedule, and for 12 months they keep their place on the ramp and rejoin without a new probation once they can work again. Workers on a visa tied to their employer get a free consult with an immigration lawyer through the federation before they're signed up. Owning part of a co-op doesn't raise the special rules some visas have for owners, which start at "more than 50 percent" of the employer or "majority voting rights" ([8 CFR 214.2(h)](https://www.law.cornell.edu/cfr/text/8/214.2)).
- **Benefits.** The share and account credits that haven't come due shouldn't count as savings for SSI or food assistance, because the bylaws bar members from getting at them early. But SSI may count a credit as income when it's made, and may count the share as unearned income, which it treats harshly. So workers on SSI or disability benefits, found through a confidential check the federation runs, not the co-op, become members only if they say yes, and get their share all in cash until Social Security says how it counts it ([How a Locked Co-op Is Taxed](taxes.md#benefits)). Opting out doesn't change how a cash share is counted, and the notice says so.
- **Unions.** Federal labor law probably doesn't protect union activity by members who own the firm. The charter protects it instead, and fixing the law takes a separate bill ([Owners Are Still Workers](labor-law.md#union-rights-the-biggest-gap)).
- **Opting out can still be pressured, and pushing people out can still be hidden.** An independent lock-holder and results-based checks make both harder. They can't make them impossible.
+40
View File
@@ -0,0 +1,40 @@
---
title: Free-Market Democratic Socialism
summary: A working model for building an economy where the people who do the work own and govern the companies they work for. Nobody's company gets taken, and markets, wages, investment and personal property all stay.
---
# Free-Market Democratic Socialism
## The idea in one sentence
**Your house, car, savings and tools are yours, and nobody touches them. But a permanent, tradeable claim on the work of thousands of strangers is a different kind of thing, so there should be companies where that claim never forms, and nothing in law or finance should stop people from building them.** [Read the thesis](thesis.md).
## Nobody is forced
This model doesn't buy, seize or convert anyone's company. Instead it builds new worker-owned companies alongside existing ones, then lets customers and workers choose between them.
- **Workers own their company.** Every member gets one vote. A share of the profits grows with tenure until long-time members split them equally, per hour worked, which is how federal tax law lets co-ops pay tax on it only once. Salaries still exist, and hard jobs still pay more. Part of each year's profit goes into a personal account that pays it back a few years later on a fixed schedule, whether or not you've left, which is designed to keep it outside federal pension law, though courts haven't settled that; where most payouts would go to people who've left, as in high-turnover work, the share is paid in cash instead. Part goes into a permanent reserve that keeps the business investing. Members can't drain it through high pay or let the equipment quietly wear out, and big bets get an outside opinion first. An insurance pool the co-ops pay for protects workers' last paychecks and part of their savings if the co-op fails. A fair, fast process deals with members who won't do the work, with protections so it can't be turned on the wrong people. When business falls off, everyone shares the cut before anyone is laid off, and layoffs follow an order set in advance that treats members and non-members alike. Members are the co-op's employees, so they get the minimum wage, overtime and injury insurance like anyone else. That also means members who run payroll, and in New York possibly every member, can personally owe wages the co-op fails to pay, so a co-op that misses a payroll stops ordinary work until it's paid up. Where the law leaves owners out, of unemployment insurance in some states or of union protection, the co-op's charter fills what it can. Membership comes automatically after a short probation, so co-ops in high-turnover work like home care don't end up run by the few who stay. Where Medicaid requires 80% of home care payments to go to care workers, co-ops meet it from wages alone. Equal shares give members a reason to turn down growth into lower-paying work, so co-ops can grow by starting new co-ops they invest in instead. The checks behind all this are scaled to size, so small co-ops get a yearly review and random audits instead of a full audit every year, though honest checking still costs them real money. [Worker Ownership](worker-ownership.md), [Does Sharing the Profit Mean Less Effort?](free-riding.md), [When Work Runs Short](downturns.md), [When People Don't Stay Long](high-turnover.md), [Owners Are Still Workers](labor-law.md), [When Growing Means Sharing](growth.md), [Member Capital Accounts](capital-accounts.md), [How a Locked Co-op Is Taxed](taxes.md), [Protecting Members' Savings](savings-guarantee.md), [Investing for the Long Run](the-long-run.md) and [What the Rules Cost a Small Co-op](compliance.md)
- **A small public bank lends to worker co-ops.** It charges market rates, so it only succeeds if co-ops can really compete. Its whole job is to be willing to lend to a kind of company other banks won't. Spain, Italy and the US have all tried versions of this, and the pilot is built around what went right and wrong. [The Bank](the-bank.md) and [Has This Been Tried?](precedents.md)
- **Owners who want to sell to their workers can.** The bank lends for these sales on a separate track, with its own $100 million and its own test. Only healthy firms qualify, there's no new tax break, the price is capped at an independent appraisal, the seller shares the risk, and the whole workforce votes. It serves deals the SBA's rules shut out, which ask a co-op, unlike an ESOP, for cash up front and one worker's personal guarantee. Its results are compared with SBA-financed purchases and ESOP sales, and its pay test follows everyone who worked there at the sale. [When an Owner Wants to Sell to the Workers](buyouts.md)
- **Co-ops funded this way stay co-ops forever.** They can't be sold to conventional owners, and members can't vote to cash out what earlier members built. An independent lock-holder can veto any attempt, including selling the business off piece by piece. If a co-op goes bankrupt, only another co-op can buy it whole, and its workers get a real chance to be that buyer. That needs an amendment to federal bankruptcy law, drafted here, and a short model state law closes the ways out that state law leaves open. [The Lock](the-lock.md), [When a Locked Co-op Goes Bankrupt](bankruptcy.md), [The Lock in State Law](lock-state-law.md)
- **It runs as an honest experiment.** A one-time $350 million pilot aims to fund about 400 new co-ops. The success measures are published before the first loan. If, after twelve years, co-ops stay open about as well as similar conventional firms while paying their members fairly, and the bank earned at least what Treasury bonds would have, Congress votes on growing it. If not, it shuts down automatically. It's built so that a hostile government can't quietly freeze it or rig the result, though Congress can always repeal it. It also tests, by lottery, whether serious help before launch gets more co-ops started. Part of the loan money is set aside for co-ops in lower-paid work, so the bank can't drift toward only the safest co-ops, and the verdict has to say which kinds of co-ops it covers. The pilot can't show whether co-ops lift pay at other employers. Even after a pass, a sector big enough for that is decades away nationally. So growth comes in capped stages, and that effect gets its own test in a few local markets. For raising pay across a whole kind of work, wage boards and unions are faster, and the model doesn't replace them. [The Test](the-test.md), [The Pilot, in Numbers](pilot-spec.md), [Help Before the First Loan](before-launch.md), [Who the Bank Lends To](who-gets-loans.md), [After a Pass: How Big, How Fast](scale.md) and [Making It Last](durability.md)
- **Co-ops back each other up.** They pool part of their profits into a shared fund. If one fails, its workers are moved into others instead of being left with nothing. [The Federation](federation.md)
- **Nobody is left behind by low-margin work.** Owning a company doesn't help much when there's barely any profit to own, so a strong floor covers the rest. A fixed formula, not lobbyists, decides which low-paid work gets public help. [Ownership and the Floor](the-floor.md) and [Who Decides What's Essential](essential-work.md)
## What stays the same
| Stays | Why |
| --- | --- |
| Existing companies | Nobody's company is bought or taken. Conventional firms keep conventional ownership. |
| Markets and competition | Co-ops and conventional firms compete for the same customers and workers. |
| Salaries | Hard and in-demand jobs still pay more. A surgeon still earns a surgeon's salary. Pay bands are open, and high pay needs the lower-paid half to agree. [Who Sets the Pay](pay.md) |
| Personal property | Homes, cars, savings and tools are untouchable. |
| Investment and big returns | Investors can still fund co-ops and make 10x or 30x. Their claim just runs out and carries no vote. [Capital, After](capital.md) |
## Why it matters
This isn't mainly about money. **People deserve a say in governing the place where they spend most of their waking lives.** Today most workers are ruled by distant owners who answer to nobody who works there. Co-ops are rare, and one likely reason is that nobody will lend to them. This model fixes the financing and lets people choose. [Making the Case](the-argument.md) and, for one real company worked through, [What If Conservice Had Been a Co-op?](conservice.md)
## Honest about what's unsolved
This is a working model, not a finished one. The biggest open question is whether strong founders will start companies they can never sell. In the success case, a co-op founder ends up with a fraction of what a conventional owner would, because the rest goes to the people who work there ([The Founder Problem](founder-problem.md)). The pilot measures founders on both sides, so if it fails, the verdict has to say how much of that was down to who applied ([Co-ops or Their Founders?](who-founds.md)). The full list of what isn't solved yet is kept in the open. [Open Problems](open-problems.md)
@@ -0,0 +1,15 @@
---
title: Intellectual Property
section: Beyond Co-ops
order: 100
summary: Targeted patent reform would help new firms, but it's kept separate from the bank so the achievable part doesn't wait on the hard part.
---
# Intellectual Property
Patent terms are too long for how fast many industries move, and some patents should never have been granted. Loosening IP would lower the barrier for any new firm, co-ops included. It belongs in the model, but it is **decoupled from the bank** for two reasons:
- **It's sector-dependent.** In software, patents mostly protect incumbents and trolls against entrants; weakening them helps new firms. In pharma and hardware, a patent is often the *only* thing keeping a small entrant from being copied to death by a competitor with better manufacturing and distribution. Blanket loosening can hand the advantage to scale, which is the opposite of the goal.
- **It's politically far heavier.** A small bank is achievable. Patent reform means fighting every industry that lives on patents. Coupling them means the achievable thing waits on the hard thing.
So the IP reform is targeted: shorter terms where innovation cycles are short, a much stricter bar for software and business-method patents, and aggressive action against patent trolls. Where patents genuinely protect small inventors, leave them alone.
@@ -0,0 +1,155 @@
---
title: Owners Are Still Workers
section: The Co-op
order: 24
summary: Labor laws were written for workers with a boss, so a co-op member can fall through the gaps, and the model closes the ones it can with the co-op's own rules and names the ones that need a change in the law.
---
# Owners Are Still Workers
A co-op member is two things at once: an owner of the business and someone who works there for pay. America's labor laws were mostly written for people who are one or the other. Most of them ask some version of "does this person have a boss?" In a co-op, the answer is "yes, and they also help elect the boss."
The model puts members on the payroll as employees ([Who can borrow](pilot-spec.md#who-can-borrow)). That gets them most of the protections any other worker has, but not all, and not in every state. This page goes law by law.
## Wages: members are employees
**Federal wage law covers members.** In 1961 the Supreme Court looked at a Maine co-op of women who knitted at home and were paid by the piece. It held that they were the co-op's employees, entitled to the minimum wage, even though they owned it. "There is nothing inherently inconsistent between the coexistence of a proprietary and an employment relationship," the Court wrote. What mattered was the reality: the co-op's managers set the rates and could expel members for bad work ([*Goldberg v. Whitaker House Cooperative*, 366 U.S. 28](https://www.law.cornell.edu/supremecourt/text/366/28)). Three justices dissented, saying a genuine co-op doesn't employ its own members, but that's not the law.
So members are owed at least the minimum wage and overtime pay for every hour, like anyone else. **A profit share doesn't count toward the minimum wage.** It's paid once a year, after the books close ([How a Locked Co-op Is Taxed](taxes.md)), and a wage has to be paid when it's earned.
### Founders' unpaid work
This catches one part of the model. Founders can put in their stake partly as unpaid work, recorded as back pay the co-op owes ([Help Before the First Loan](before-launch.md#should-founders-put-in-money-of-their-own), [The Founder Problem](founder-problem.md#the-tools)). Once the co-op exists and founders are its employees, working below the minimum wage breaks the law, even by agreement. Workers can't sign away the minimum wage.
There is an exception in federal law for an owner of at least 20% of a business who actively manages it ([29 CFR 541.101](https://www.law.cornell.edu/cfr/text/29/541.101)). The model doesn't rely on it. A locked share can't be sold, so it may not count as a real ownership stake. The exception doesn't reach teams of six or more. And it's federal only, so state wage laws may still apply. So the rule is:
- **Before the co-op is formed, work can be unpaid.** Founders who are working on their own venture don't yet have an employer. They sign the bank's short standard founding agreement as equal partners, so it's clear nobody is working for anyone else. Their logged hours are turned into back pay when the co-op forms.
- **Once the co-op is formed, everyone is paid at least the minimum wage for every hour,** founders included, at the state or local minimum where that's higher. Only the difference between the minimum wage and the local median pay for the job can be recorded as back pay and counted toward the stake. The first loan can pay those wages.
- **Nobody works for free who isn't a founder.** Anyone who works on the venture after the application goes in is either a named founder or paid.
- **The bank asks the Department of Labor for an opinion letter** on this setup before lending starts, and follows state law where it's stricter.
That makes the stake a bit harder to earn in work, since hours after the co-op forms count for less. It's still much easier than a cash buy-in.
### Overtime and the profit share
Overtime is paid at one and a half times a worker's "regular rate," and a bonus that workers are promised in advance usually has to be added into that rate ([29 CFR 778.211](https://www.law.cornell.edu/cfr/text/29/778.211)). Both the profit share and the [team bonuses](free-riding.md#team-bonuses-the-members-design) are promised in advance.
There's a way out for the profit share. A written profit-sharing plan that pays out of the whole business's profits, by a set formula such as hours worked, isn't added into the regular rate. But the plan has to be open at least to every worker covered by the wage law, not just members, though it can require a minimum length of service ([29 CFR 549.1](https://www.law.cornell.edu/cfr/text/29/549.1)). It has other conditions too:
- **The model's profit share probably doesn't meet it.** The part credited to members' accounts isn't paid "within a reasonable period" or put in a trust, as the rule requires. And a share "may not be diminished because of any other remuneration," but the model's pay rule takes part of above-market pay out of the profit share ([Investing for the Long Run](the-long-run.md#the-rules)).
- **So the whole profit share is added into the overtime rate.** Every year, once the profit share is set, the standard payroll service recalculates overtime for every worker who had any and pays the difference. Overtime is a small share of hours in most co-ops, so the cost is small.
- **Team bonuses are always added into the overtime rate.** The standard payroll service does the math.
### Meetings are work
Time at a required meeting counts as working time under federal law ([29 CFR 785.27](https://www.law.cornell.edu/cfr/text/29/785.27)). So members are paid for general meetings they're expected to attend and for time on the board and committees, as [When People Don't Stay Long](high-turnover.md#unions-stay) already requires. That also keeps governance from costing low-paid members a shift.
### Is the profit share pay?
Members get two kinds of money: wages, paid through payroll at no less than the going rate, and a share of the profit, split by hours worked once the year's books close. The model's position is that **a member's profit share is an owner's share of profit, not pay.** It depends on the whole co-op making money, it's declared only after the year ends, and wages always come first ([How a Locked Co-op Is Taxed](taxes.md#wages-first)). But each law asks its own question, and some of the model's own rules point the other way. The share is split by hours, it grows with years of service, and workers who chose not to join get one too, in cash ([How a Locked Co-op Is Taxed](taxes.md#one-rate-per-hour-for-everyone-who-works)). For them it's simply a bonus. So the answers differ by law:
- **Tax.** The model argues it's a patronage dividend, which is how co-op tax law pays owners, and that it doesn't carry payroll tax. That's unsettled ([How a Locked Co-op Is Taxed](taxes.md#payroll-tax-on-profit-shares)). For lower-income members' credits that aren't taxed until paid, the co-op also follows the tax rules for deferred pay, as a precaution, without conceding that they apply ([How a Locked Co-op Is Taxed](taxes.md#credits-taxed-later-the-deferred-pay-rules)).
- **Overtime.** Federal overtime law counts almost everything an employer pays, so the co-op adds the share into the overtime rate, as above. Its definition is broader than the tax law's, so this doesn't settle the tax question either way.
- **Pension law.** Members' accounts are designed to stay outside it. The model argues first that nothing is held back until people leave. In the alternative, if the share counts as pay, it argues that it's a bonus under the Labor Department's rule. That fallback concedes, for pension law only, the point the model resists elsewhere ([Member Capital Accounts](capital-accounts.md#why-not-pay-it-out-when-you-leave)).
- **Medicaid's home care pay rule.** From 2030, agencies must spend 80% of certain Medicaid home care payments on care workers' pay. That rule borrows the overtime law's broad meaning of pay, so the share might count. The co-op doesn't count it anyway, to keep one position everywhere, and pays any shortfall as wages ([When People Don't Stay Long](high-turnover.md#when-medicaid-decides-how-much-goes-to-pay)).
- **State laws on paying wages.** This is the hard one. Many states require wages to be paid on time and in full, forbid employers from taking any back, require final pay within days when someone leaves, and don't let workers sign those rights away. California's definition of wages covers "all amounts for labor performed by employees," however they're calculated ([Cal. Labor Code § 200](https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=LAB&sectionNum=200)). It's unlawful there for an employer "to collect or receive from an employee any part of wages theretofore paid" ([§ 221](https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=LAB&sectionNum=221)), and no private agreement can set those rules aside ([§ 219](https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=LAB&sectionNum=219)). If a credit in a member's account counted as wages there, holding it for years, charging losses to it and pausing it in a bad year could all be illegal, for members who stay as well as those who leave.
State co-op law supports the owner's-share reading, but it doesn't settle it. Massachusetts, California and Washington each have a law for worker co-ops that lets a member's share of profit be paid in "cash, credits, written notices of allocation, or capital stock," lets the bylaws "permit the periodic redemption of written notices of allocation," and forbids payouts that would make the directors personally liable ([Mass. Gen. Laws ch. 157A, §§ 8–9](https://malegislature.gov/Laws/GeneralLaws/PartI/TitleXXII/Chapter157A/Section9); California's is Corporations Code § 12317, [added in 2015](http://www.leginfo.ca.gov/pub/15-16/bill/asm/ab_0801-0850/ab_816_bill_20150812_chaptered.html); Washington's is [RCW 23.78.090](https://lawfilesext.leg.wa.gov/Law/RCW/RCW%20%2023%20%20TITLE/RCW%20%2023%20.%2078%20%20CHAPTER/RCW%20%2023%20.%2078%20.090.htm)). Those laws say how the money can be paid. None of them says it isn't wages, and a wage law's ban on signing rights away is the more specific rule. How far each state's wage law reaches pay that depends on the whole business's profit differs from state to state and isn't settled. So:
- **Where it's unclear, the co-op doesn't bet on it.** The bank publishes a checked entry for each state, signed by a lawyer licensed there and reviewed every year, saying whether its wage law could reach account credits. Before lending in a state, the bank asks the state's labor agency for its view. Where the answer is "could," co-ops in that state don't use members' accounts. The members' share is paid all in cash, and a co-op that wants more money to invest sends more of its profit to the permanent reserve instead. Members there don't build savings in the co-op until the state's law changes.
- **The model state law fixes it at the source.** It amends the state's wage-payment law itself, not only its co-op law: a written share of profit that a business allocates to its owners, under a formula set in advance and only after every worker has been paid at least the going rate, isn't wages for that law. It applies to any business that does this, not only co-ops, so it isn't a favor to one kind of firm. It sits in the short law that already recognizes locked co-ops ([Making It Last](durability.md#federal-test-state-copies)).
- **Every credit comes with a written notice.** Each year, each person gets a notice showing the amount credited and when it's scheduled to be paid, which is what those co-op laws describe. It also says plainly that the credit isn't a wage, that it can be reduced by losses or paused until it's paid, and where to file a wage claim if they think wages are owed.
- **The going rate is checked.** The owner's-share argument only holds if wages really come first. So the co-op's outside accountant tests each year that every worker was paid at least the going rate, against the public benchmark in [the pay rule](the-long-run.md#the-rules), and any shortfall is paid as wages before any profit is shared. In small co-ops that's a fixed test of every worker's pay rather than a full audit ([What the Rules Cost a Small Co-op](compliance.md#one-package-done-once-a-year)).
**What members give up.** Calling the share an owner's share has costs. Unpaid wages get priority in a bankruptcy, up to $17,150 per worker ([11 U.S.C. § 507(a)(4)](https://www.law.cornell.edu/uscode/text/11/507)). Account credits don't, and they rank behind the co-op's lenders ([Member Capital Accounts](capital-accounts.md#protecting-the-co-op-and-the-people-who-stay)). Unemployment and injury benefits are figured from wages, so the share doesn't raise them. Federal limits on garnishment cover any "bonus" paid for work ([15 U.S.C. § 1672](https://www.law.cornell.edu/uscode/text/15/1672)), so a cash share probably gets that protection anyway. The [savings insurance pool](savings-guarantee.md) covers part of the bankruptcy gap for people who have left.
### When wages go unpaid, members can owe them
Some states make a company's owners or officers personally pay wages the company doesn't, and federal law does the same for people who run it. The laws were written for owners who pocket the profit and leave the workers unpaid. In a worker co-op, the owners and officers are workers too, so the bill can land on the co-op's own members, including its lowest-paid ones. The members owed wages and the members who owe them can be the same people.
- **Federal law reaches people in charge, in every state.** The Fair Labor Standards Act counts as an employer "any person acting directly or indirectly in the interest of an employer" ([29 U.S.C. § 203(d)](https://www.law.cornell.edu/uscode/text/29/203)). Courts have read that to reach officers and managers who control the business's operations, and an unpaid minimum wage or overtime is owed again as damages, so twice over ([§ 216(b)](https://www.law.cornell.edu/uscode/text/29/216)). It covers only the minimum wage and overtime, not the rest of a worker's pay. No state law can take this away.
- **New York appears to reach every member.** For an ordinary private corporation or LLC, only the ten largest owners are personally liable for unpaid wages ([Business Corporation Law § 630](https://newyork.public.law/laws/n.y._business_corporation_law_section_630); [LLC Law § 609(c)](https://newyork.public.law/laws/n.y._limited_liability_company_law_section_609)). For a co-op it's broader: every member and every director of a cooperative corporation is personally liable, each for the whole amount, for wages owed to its employees ([Cooperative Corporations Law § 47](https://newyork.public.law/laws/n.y._cooperative_corporations_law_section_47)), and the co-op can't opt out of that in its charter. The state's worker co-op article applies the co-op chapter except for listed sections, and § 47 isn't listed ([§ 85](https://newyork.public.law/laws/n.y._cooperative_corporations_law_section_85)). The same article says a worker co-op is governed by the corporation law ([§ 84](https://newyork.public.law/laws/n.y._cooperative_corporations_law_section_84)), so a court could read it the other way, but the ten-largest rule isn't among the corporation-law sections it brings back. I haven't found a court case either way. "Wages" includes severance, vacation pay, and benefit and pension contributions. A worker has to give written notice within 180 days after the work ends, and can sue a member only after a judgment against the co-op goes unpaid. A member who pays more than their share can recover the excess from the other members. Choosing another form doesn't escape it: in a co-op of ten or fewer equal members, the ten-largest rule reaches everyone anyway, and both rules apply to companies formed in other states when the work is done in New York.
- **Massachusetts reaches officers and managers.** The president, treasurer and any officers or agents managing a corporation count as the employer under the state's wage law ([ch. 149, § 148](https://malegislature.gov/Laws/GeneralLaws/PartI/TitleXXI/Chapter149/Section148)), and unpaid wages are owed three times over, plus legal costs ([§ 150](https://malegislature.gov/Laws/GeneralLaws/PartI/TitleXXI/Chapter149/Section150)). Workers can't waive this. In a co-op, those officers are members the others elected, and they may be paid no more than anyone else.
- **New Jersey's closing law may reach owners and managers.** Its severance duty, a week per year worked in large closings and layoffs, falls on the "employer," which it defines to include anyone acting in the employer's interest, anyone who owns and operates it, and whoever makes the decision behind the layoffs ([P.L. 2019, c. 423](https://pub.njleg.state.nj.us/Bills/2018/PL19/423_.HTM); [Protecting Members' Savings](savings-guarantee.md#notice-pay-and-severance)). In a co-op, the members own and operate it.
- **California reaches owners and managers who cause it.** An owner, director, officer or managing agent who violates certain wage rules, "or causes" them to be violated, can be held liable as the employer ([Labor Code § 558.1](https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=LAB&sectionNum=558.1)). That includes the penalty for final pay not paid on time, which keeps running as a day's wages per day for up to 30 days ([§ 203](https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=LAB&sectionNum=203)). "Causes" probably means some personal part in the decision, so officers who decided to keep people working are at risk, and a member who only voted probably isn't.
So the model does four things:
- **No working on credit.** A payroll counts as missed when wages are still unpaid five business days after the day state law says they're due. The board declares it and tells the pool the same day, and the pool can declare it if the board doesn't. From then on, the co-op can't schedule more ordinary work until everyone is paid up, unless it files for bankruptcy protection or the federation's fund lends it the payroll. The fund decides on a loan within two business days, and its loan is repaid after workers' wage claims, not before. Work to keep people safe, care for people or animals, save perishable goods and wind down in order goes on, and the pool pays for it first. This keeps a failing co-op from running up unpaid wages its own members might have to cover ([When Work Runs Short](downturns.md#when-cutting-isnt-enough)).
- **Stopping and federal notice rules.** A sudden stop at a co-op with 100 or more workers can count as a plant closing that needed 60 days' notice ([29 U.S.C. § 2102](https://www.law.cornell.edu/uscode/text/29/2102)). The law shortens that when the business was seeking money that notice would have scared off, or when the cause wasn't foreseeable, but the employer must still give as much notice as it can. So a co-op whose quarterly accounts show the pool's warning signs ([Keeping Workers' Savings Safe](savings-guarantee.md#who-runs-it-and-how-claims-are-paid)) gives conditional notice early rather than waiting for the missed payroll.
- **The insurance pool pays first.** The [pool](savings-guarantee.md#the-insurance-pool) covers up to eight weeks of each worker's unpaid wages, then notice pay ([Notice pay and severance](savings-guarantee.md#notice-pay-and-severance)). If the rule above is kept, that covers most unpaid wages, but notice pay, vacation, severance and benefit contributions can go past it. The pool doesn't pursue ordinary members for what it pays, and the co-op's bylaws and officers' insurance cover elected officers who kept the rule and didn't hide losses. Federal and Massachusetts liability can't be waived, but it can be insured.
- **The model state law narrows it.** In states that adopt it, a member of a locked co-op covered by the pool isn't personally liable just for being a member, for claims by other members or for anything the pool paid. Workers who aren't members keep their full claims for anything the pool didn't pay, since they had no vote. There's no shield for wages earned after a payroll was missed and the rule wasn't kept, or for anyone who ran payroll or kept people working ([The Lock in State Law](lock-state-law.md#what-the-model-law-does), point 10). It doesn't touch federal law.
Until a state adopts that, members in states like New York take on a real personal risk. The risk follows where the work is done, not where the co-op was formed, and the bank's checked entry for each state says what it is. Every member is told in writing when they join, with the rules of the state where they work.
## Unemployment insurance: covered in most states, not all
Federal unemployment tax treats a corporation's officers as employees. But each state runs its own unemployment insurance, and several write owners or officers out of it ([US Department of Labor, *Comparison of State Unemployment Laws*, 2021, table 1-7](https://oui.doleta.gov/unemploy/pdf/uilawcompar/2021/coverage.pdf)). Examples:
- **New Jersey** excludes corporate officers and anyone who owns at least 5% of the business. In a co-op with fewer than 20 members, every member owns more than 5%.
- **Minnesota and Wisconsin** limit benefits for someone who owns 25% or more. In a co-op of three or four founders, every founder does.
- **Washington and South Carolina** leave corporate officers out unless the corporation chooses to cover them. Members often serve as officers.
For a member who loses their job when the co-op fails, that's the worst time to find out they're not covered.
**Washington has partly fixed it.** Since 2024, officers of worker co-ops there no longer lose benefits for owning 10% or more of the company, and serving only as an officer doesn't count as working ([Laws of 2023, chapter 92](https://lawfilesext.leg.wa.gov/biennium/2023-24/Pdf/Bills/Session%20Laws/Senate/5176-S.SL.pdf)). The co-op still has to choose to cover its officers ([RCW 50.04.165](https://app.leg.wa.gov/RCW/default.aspx?cite=50.04.165)).
**The model's rules:**
- **Always cover members.** Where a state lets a co-op choose whether to cover its officers or owners, a locked co-op chooses to cover them. That's a condition of the bank's loan and part of the charter.
- **Members are told in writing** before they join if their state leaves them out, the same notice Washington already requires for officers who aren't covered.
- **A model state law copies Washington and goes further.** It's a separate short law a state can adopt on its own, apart from [the lock's state law](lock-state-law.md): members of a locked co-op are covered like any other worker, and their membership share doesn't count as ownership when they claim.
- **Owners can't lay themselves off in turns.** The worry behind the state exclusions is real: owners can arrange their own layoffs. So under the model law, a member laid off in a rotation isn't counted as unemployed, except through the state's work-sharing program where it has one. Layoffs follow the co-op's [written layoff order](downturns.md#the-layoff-order), and like any employer, a co-op whose members claim benefits pays a higher tax rate.
- **Where a state still excludes members,** co-ops in that state can band together through the [federation](federation.md) for their own unemployment cover. Mondragon did this. When Spain excluded co-op members from its social security system in 1958, the co-ops built their own mutual insurer, Lagun Aro, and later added an unemployment benefit ([MONDRAGON, *History*](https://www.mondragon-corporation.com/en/history/)). In America, a shared fund like that is insurance, so it has to be licensed by the state or set up as a special trust the tax law allows for unemployment pay. It's optional.
- **The pilot counts the gap.** It publishes how many members, by state, had no unemployment cover, and what happened to them when they lost their jobs.
## Workers' compensation: covered, unless someone opts out
Injury insurance usually covers corporate officers and directors who work for pay, but many states let owners opt out. California, for example, covers officers and board members "while rendering actual service for the corporations for pay" ([Cal. Labor Code § 3351(c)](https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=LAB&sectionNum=3351)). But an officer or director who owns at least 10% of the company and has health insurance can sign a waiver ([§ 3352(a)(16)](https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=LAB&sectionNum=3352)). In a co-op of ten or fewer members, every member owns that much, and the board is made of members.
**The model's rule: nobody opts out.** Every member, officer and board member who works for the co-op is covered, as a condition of the loan. Opting out saves a little on premiums and leaves an injured worker with far less.
The same goes for state paid sick leave and paid family leave where a state lets owners or officers opt out. The charter's own leave protections ([Does Sharing the Profit Mean Less Effort?](free-riding.md#when-a-member-wont-do-the-work)) cover small co-ops that federal family leave law, which starts at 50 workers, doesn't reach.
## Anti-discrimination law: probably covered, and the charter covers the rest
Federal anti-discrimination laws protect employees, not owners. In 2003 the Supreme Court said the question is control: does the person "act independently and participate in managing the organization," or are they "subject to the organization's control"? It listed six factors, including whether the organization can fire them, whether they report to someone, and whether the parties intended them to be an employee. It also said a job title or a written agreement doesn't settle it ([*Clackamas Gastroenterology Associates v. Wells*, 538 U.S. 440](https://www.law.cornell.edu/supct/html/01-1435.ZO.html)).
A co-op member who reports to a manager and can be dismissed looks like an employee on most of those factors. **The standard membership agreement says in writing that members are employees of the co-op for every employment law.** That's one factor, not a guarantee, but it's the one the co-op controls.
The main federal laws don't apply to employers with fewer than 15 workers (20 for age discrimination), and many co-ops will be that small. Many state laws reach smaller employers. California's, for example, starts at five ([Cal. Gov. Code § 12926(d)](https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=GOV&sectionNum=12926)). Below that, the charter does the work. The dismissal process bars discrimination and retaliation for every member, whatever the co-op's size ([Does Sharing the Profit Mean Less Effort?](free-riding.md#when-a-member-wont-do-the-work)). The charter extends the same ban to every job decision: hiring onto probation, pay, schedules and harassment. None of it replaces a worker's right to go to a government agency or a court. The charter says so, and it bars any agreement that would make a worker give that up.
## Union rights: the biggest gap
**The federal labor relations law (the NLRA) probably doesn't protect members' own union activity.** It protects employees, and the labor board has long excluded worker-owners who, as a group, control the company. In a 2002 case about a taxi company owned by its drivers, the board found the drivers weren't protected employees, because together they could elect and remove the board, officers and committees, repeal work rules, and change the company's constitution (*Citywide Corporate Transportation*, 338 NLRB 444). They also worked alongside employees who weren't owners, whose interests could differ. That's a close description of a locked co-op.
The board has gone the other way when *everyone* who works there is an owner and the owners' interest as workers is "at least as great" as their interest as owners, as in a 1953 case about a plywood co-op (*Everett Plywood & Door*, 105 NLRB 17). But the model allows up to a quarter of hours to be worked by people who aren't members yet. So members of a locked co-op would most likely fall on the unprotected side. The cases are collected in [Levinson, "Union Co-ops and the Revival of Labor Law," *Cardozo Journal of Conflict Resolution* vol. 19, 2018](https://static1.squarespace.com/static/60a5863870f56068b0f097cd/t/60a5e69e188d6e641ff01621/1621485214238/Union-Co-ops-and-the-Revival-of-Labor-Law.pdf).
**What that means in practice:**
- **Workers who aren't members are fully protected.** People on probation, and anyone else who isn't a member, can organize, and the co-op can't punish them for it. Members wouldn't be in their bargaining unit.
- **A co-op can still recognize a union voluntarily.** Nothing stops a co-op from signing a contract with a union that covers everyone, members included, as Cooperative Home Care Associates in the Bronx has done with 1199SEIU ([When People Don't Stay Long](high-turnover.md#unions-stay)). The difference is that if a co-op fired a member for union activity, the labor board probably couldn't step in.
**The model's rules:**
- **The charter protects members who organize,** and members can't vote it away. It already bars dismissing a member within 12 months of organizing unless the co-op proves the reason was something else ([Does Sharing the Profit Mean Less Effort?](free-riding.md#when-a-member-wont-do-the-work)). When a member says they were dismissed for organizing, the case goes straight to an outside arbitrator from the federation's list, paid for by the bank, not to a hearing of the same members they were organizing against. If the dismissal is overturned, the member gets their job back with back pay.
- **Recognition sticks.** Once a co-op recognizes a union, it can withdraw recognition only if a majority of the workers the union covers, members and non-members together, vote for that.
- **A short change in federal law, as its own bill.** It amends the NLRA's definition of "employee" to say that membership in a worker co-op, and the powers members hold together, don't by themselves take a worker outside the law. Members who actually supervise others are still excluded, like supervisors anywhere. It's kept out of the bank's law, so a fight over labor law can't sink the pilot.
**The change needs one more piece.** The NLRA forbids an employer from dominating or funding any "employee representation committee or plan" that deals with it over pay or working conditions ([29 U.S.C. 152(5)](https://www.law.cornell.edu/uscode/text/29/152), [158(a)(2)](https://www.law.cornell.edu/uscode/text/29/158)). That rule exists to stop company unions. But once members count as employees, a co-op's member meetings and elected committees, which deal with pay and working conditions all the time, could look like exactly that. So the same bill says a worker co-op's own governance isn't a company union, and it protects members only from being punished for organizing. It doesn't force a co-op to bargain with a union of members alone.
## Other laws that apply
- **Workplace safety law covers members,** including its protection against being punished for reporting a hazard. The charter's retaliation rule covers safety complaints too.
- **Health insurance.** The federal rule requiring employers to offer health coverage starts at 50 full-time workers, so it won't reach most co-ops. Members can use the public marketplace, and the federation can arrange a group plan.
- **The buy-in doesn't come out of pay** unless a member asks in writing. It comes out of profit shares ([When People Don't Stay Long](high-turnover.md)), which keeps co-ops clear of state laws that limit deductions from wages.
- **Workers without work authorization can't be members,** as [How a Locked Co-op Is Taxed](taxes.md) explains. Every co-op still owes every worker the minimum wage and a safe job, and the charter bars threats about anyone's immigration status.
## What this doesn't solve
- **The charter is weaker than law.** A member fired for organizing gets an outside arbitrator, not the labor board. Until the NLRA is changed, that's the protection.
- **The NLRA change is new federal law,** and it may never pass. Opponents will say it's odd for owners to organize against themselves. The answer is that a member has one vote among many, and the managers they elect can still treat them badly.
- **State unemployment law changes slowly.** Until states adopt the model law, members in some states have no unemployment cover unless their co-ops set up their own. Making that cover required would raise costs only for co-ops in those states. The pilot leaves it optional and counts who went without.
- **The founding setup hasn't been tested.** Whether unpaid work before the co-op is formed is really outside wage law is a question for the Labor Department's opinion letter, and whether members' accounts stay outside pension law is another ([Member Capital Accounts](capital-accounts.md#why-not-pay-it-out-when-you-leave)). Until it answers, the bank's lawyers decide, and they could be wrong.
- **Workers without papers are still left out** of membership, and so out of the profit share.
- **This is a map, not legal advice.** State rules differ and change, and the Department of Labor's table is from 2021. Instead of every co-op paying a lawyer, the bank keeps a checked set of rules for each state and runs a shared payroll service that handles minimum wage, overtime and insurance.
@@ -0,0 +1,106 @@
---
title: The Lock in State Law
section: The Co-op
order: 31
summary: The lock is written into each co-op's charter, but ordinary state law still leaves several ways out, and this page drafts a short law any state can adopt to close them for co-ops that choose the lock.
---
# The Lock in State Law
[The lock](the-lock.md) is built from a co-op's own founding documents and a single *lock share* held by an independent lock-holder, which can veto a sale or a change to the lock. Most of that works under today's law. But co-ops and companies are creatures of state law, and state law has its own ways out that a charter can't fully close by itself.
This page lists those ways out and drafts a short model law that closes them. It applies only to co-ops that choose the lock. States can adopt it or not, and nothing in the federal law requires them to. A co-op in a state that hasn't adopted it is still locked by its charter and lock share, just less securely.
## Where state law leaks
- **Members can vote to stop being a co-op.** Massachusetts has had a law for worker co-ops since 1982, and a co-op can leave that status "by a vote of two-thirds of the members" ([Mass. Gen. Laws ch. 157A, § 4](https://malegislature.gov/Laws/GeneralLaws/PartI/TitleXXII/Chapter157A/Section4)). That's exactly the exit the lock closes.
- **Mergers and changes of form usually need only the owners' vote.** In Delaware, for example, a merger is approved by "a majority of the outstanding stock" entitled to vote ([Del. Code tit. 8, § 251(c)](https://delcode.delaware.gov/title8/c001/sc09/index.html)). Most states also let a company turn itself into a different kind of business, or move to another state, by a vote. A lock share catches these only if the charter says its consent is needed, and only as far as each state's law lets a charter say so.
- **Some protection already exists, unevenly.** Delaware already gives a class of shares its own vote on any change that would "alter or change the powers, preferences, or special rights of the shares of such class so as to affect them adversely" ([Del. Code tit. 8, § 242(b)(2)](https://delcode.delaware.gov/title8/c001/sc08/index.html)). So a Delaware lock share can block its own removal. Co-op laws often don't have classes of shares at all, and they vary a lot from state to state.
- **Courts can end a company.** Massachusetts, like most states, lets a court dissolve a corporation when its directors or owners are deadlocked, or when its creditors can't be paid ([Mass. Gen. Laws ch. 156D, § 14.30](https://malegislature.gov/Laws/GeneralLaws/PartI/TitleXXII/Chapter156D/Section14.30)). After debts are paid, what's left normally goes to the owners. A charter can say otherwise, but whether a court has to follow that when it's the one dissolving the company isn't settled everywhere.
- **Courts dislike permanent limits on selling.** A lock is a "restraint on alienation," a limit on selling property, and courts look hard at those when they last forever ([Cornell Legal Information Institute](https://www.law.cornell.edu/wex/restraint_on_alienation)). Limits on selling company shares are usually allowed if they're reasonable, but "reasonable" is up to a judge.
- **Creditors can sell without a bankruptcy court.** A lender with a claim on a business's equipment and other assets can sell them after a default, or keep them, under state law ([UCC § 9-610](https://www.law.cornell.edu/ucc/9/9-610)). A mortgage lender can foreclose on its building. A creditor with a court judgment can have the sheriff sell its property, a tax agency can hold a tax sale, a state court receiver can sell the business, and so can an assignee when a business hands everything over to pay its creditors. The [bankruptcy amendment](bankruptcy.md#what-can-be-sold-and-to-whom) tries to reach those sales with a federal law resting on Congress's power over commerce, which will be challenged. A state law covering the same sales is on much firmer ground.
## What the model law does
**1. Locking is a choice, and people who vote no can leave.** A co-op becomes locked by saying so in its articles, naming a lock-holder from the approved list and issuing the lock share. For a new co-op, the founders decide it unanimously. An existing co-op or business, like one being [sold to its workers](buyouts.md), needs at least a two-thirds vote of its members, and never less than the vote it would take to leave co-op status. Members who vote no can leave within 90 days and have their whole capital account paid out on the normal schedule. They aren't paid what the business would sell for unlocked. That value never belonged to any one member, and paying it out is exactly the windfall the lock exists to prevent. The status is shown in the state's public business records, and a notice is filed where lenders already look for claims on a business's property (a UCC filing).
**2. The members' exit doesn't apply.** Once locked, a co-op can stop being a co-op only with the lock-holder's consent, and the lock-holder can consent only to two things: merging into another locked co-op, or closing with its remaining value going to the co-op movement's fund. Any member vote that a state's co-op law would otherwise allow, like the Massachusetts two-thirds vote, doesn't apply to a locked co-op. The lock clauses in the articles, the named fund and the lock-holder can't be changed without the lock-holder's consent either. This is the one real change to members' rights, and it's the point of the law.
**3. The lock is valid by law.** The lock share and the lock's limits on selling are declared reasonable and not an unlawful restraint on alienation. Anyone who takes over the business with notice of the lock is bound by it, including a receiver, a trustee or a buyer. Notice comes from the public record and the UCC filing.
**4. The lock-holder has rules too.** Without its consent, a locked co-op can't merge, convert or move to another state except into another locked co-op, and can't sell, lease or license more than a quarter of its assets or its revenue-producing business within any three years, counted together, or its brand, core technology or main customer contracts, whatever their size ([Closing the back doors](the-lock.md#closing-the-back-doors)). Ordinary sales, like selling stock to customers or replacing old equipment with new, don't count. The quarter is measured on the co-op's books at the start of the three years. Companies the co-op controls are locked too. But the lock-holder isn't free to say yes or no as it likes:
- **It must say yes** to a sale at a fair price of things the co-op no longer needs, and to spinning off part of the business as a new locked co-op.
- **It must say no** to anything that would leave most of the business, its brand, technology or main contracts in the hands of anyone but a locked co-op.
- **Silence means yes.** If it hasn't answered a request it must approve within 30 days, the answer is yes.
- **It can be challenged and replaced.** Members can take a refusal to court quickly, and the lock-holder pays their costs if the refusal was unreasonable. Members can also replace their lock-holder by a two-thirds vote, but only with another one on the approved list, and not in the middle of a deal it's reviewing.
**5. Breaking the rules doesn't pay.** The state's business filing office must refuse any filing for a merger, conversion, move or change to the lock clauses that breaks them. Asset sales aren't filed with the state, so for those the remedy is different. A sale that breaks the lock can be undone against a buyer who knew about it. A buyer who paid a fair price without knowing keeps what it bought, but the co-op's gain from the sale goes to the fund, and the insiders who arranged it are liable for the rest. That protects honest buyers, which lenders and buyers need before they'll deal with a locked co-op at all.
**6. Closing hands the value on, even when a court does the closing.** When a locked co-op closes, by its own vote or by a court's order, what's left after its debts and the members' own account balances ([Member Capital Accounts](capital-accounts.md)) goes to the co-op movement's fund named in its articles. Balances count only as allocated under the co-op's published rules. Anything added to members' accounts in the two years before closing, above the average of the three years before that, goes to the fund instead, so members can't empty the reserve into their own accounts on the way out. If the named fund no longer exists, the lock-holder names the closest one, and the attorney general does only if the lock-holder can't. Courts keep every power they have to dissolve a company and to protect members who've been treated unfairly, paid from their own accounts. What they lose is the power to hand the rest to the members.
**7. Sales outside bankruptcy follow the same rules as inside it.** The rules are written into the state law itself, so they don't depend on a federal law that might be changed or never passed. They apply when a lender sells or keeps a locked co-op's property, a mortgage is foreclosed, a sheriff sells for a court judgment, a tax sale is held, a receiver sells, or the co-op hands everything over to pay its creditors, if what's sold adds up, over three years, to more than a quarter of the business, or includes its brand, technology or main contracts. They're the rules in [When a Locked Co-op Goes Bankrupt](bankruptcy.md#what-can-be-sold-and-to-whom):
- for 90 days, only other locked co-ops, the federation and a co-op formed by the workers can buy the business whole, at a price no lower than what the pieces would bring, set by an independent appraiser;
- after that, the pieces can go to anyone, but no buyer can put the business back together, and a buyer who hires more than a third of the members within two years owes the difference to the co-op's creditors;
- creditors who never chose to lend, like people the co-op injured and tax agencies, are paid by the federation fund anything the lock cost them, as in bankruptcy.
A lender that follows these rules has sold in a "commercially reasonable" way under the UCC, so it can't be penalized for the lower price. A clause in a loan agreement choosing another state's law to avoid these rules is void in the adopting state's courts. A lender whose claim was on file before the co-op locked, and who didn't agree to the lock, isn't bound.
**8. Several parties can enforce it.** The lock-holder, any member, any other locked co-op, the bank, and the state attorney general can all go to court to enforce the lock. That matches [The Lock](the-lock.md#how-the-lock-is-made-to-hold). Anyone who brings a frivolous case pays the other side's costs, as in [bankruptcy](bankruptcy.md#everything-else), so competitors can't use lawsuits to hold up each other's deals.
**9. A backstop if the lock breaks anyway.** If a court still lets a locked co-op convert or be sold, the value above creditors and members' own balances goes to the fund, not to the members. That's layer 4 of the lock, now written into state law. Nobody gets a windfall from breaking it. This part has real precedent. Italian co-ops that take the tax benefits for being mainly mutual must write into their bylaws that their whole net value, apart from members' paid-in capital, goes to the movement's funds when they close ([Italian Civil Code, art. 2514](https://www.brocardi.it/codice-civile/libro-quinto/titolo-vi/capo-i/sezione-i/art2514.html)), and the same happens if they become an ordinary company ([art. 2545-undecies](https://www.brocardi.it/codice-civile/libro-quinto/titolo-vi/capo-i/sezione-v/art2545undecies.html)). Italy has sent that value to the funds since 1992 ([Law 59/1992, art. 11](https://www.diritto.it/i-fondi-mutualistici-per-la-promozione-e-lo-sviluppo-della-cooperazione/)). But Italy lets a co-op change its form. It locks the value, not the co-op, and that's the softer alternative [The Lock](the-lock.md#what-the-lock-costs) leaves open.
**10. Members aren't wage guarantors just for being members.** Some states make a company's owners personally liable for its unpaid wages, and New York makes every member of a co-op liable ([Owners Are Still Workers](labor-law.md#when-wages-go-unpaid-members-can-owe-them)). In a locked co-op, those owners are its workers. So a member of a locked co-op that's covered by the insurance pool isn't personally liable just for being a member, for claims by other members or for wages the pool paid. Workers who aren't members keep their full claims for the rest. People who ran payroll or kept others working, whatever their title and even if they stepped down in the 90 days before, stay liable as the state's law provides, and so does anyone paid in full, or paid anything else by the co-op, while others went unpaid. The shield doesn't cover wages earned after a missed payroll if the co-op kept working on credit. Federal law isn't affected.
**What it leaves alone.** It changes no taxes, sets no rules for co-ops that didn't choose the lock, and doesn't require any co-op to lock. Everything else about running a co-op stays with the state's ordinary co-op or corporate law.
## Which state's law counts
Under a long-standing rule, a company's internal affairs, like who votes on a merger, whether members can leave co-op status, and where its value goes when it closes, are governed by the law of the state where it's organized, not where it does business. So a co-op can organize under the model law in a state that has adopted it, wherever it works, the way many companies organize in Delaware. The bank lends to co-ops organized anywhere, but it publishes which states have adopted the law.
That rule covers less than it sounds like:
- **Property is different.** Sales of land follow the law of the state where the land sits. A receiver or sheriff in another state follows that state's law. Lenders' rules for equipment may too.
- **Lenders pick their law.** A loan agreement can choose which state's law governs it ([UCC § 1-301](https://www.law.cornell.edu/ucc/1/1-301)). The model law voids a choice made to avoid the lock, but only in the adopting state's own courts.
- **Some states reach in anyway.** California applies some of its own corporate rules to companies organized elsewhere that do most of their business there and are mostly owned by people who live there ([Cal. Corp. Code § 2115](https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=CORP&sectionNum=2115)).
Outside the adopting state, the co-op's charter, the lock share and the federal rules are what's left.
## Draft text
A short version, written to be dropped into a state's co-op or corporate law. Section numbers are placeholders.
> **Sec. 1. Definitions.** (a) A "locked cooperative" is a cooperative or corporation whose articles (1) state that it is a locked cooperative under this Act, (2) name a lock-holder approved under Sec. 9, and (3) name a cooperative development fund. (b) The "lock share" is the single share issued to the lock-holder. (c) A "lock-preserving transaction" is a merger or consolidation into, or a transfer of the business to, another locked cooperative, or a dissolution under Sec. 6. (d) A "covered transfer" is any transfer, lease or license, voluntary or not, that together with others in the preceding 36 months exceeds 25 percent of the book value of the cooperative's assets at the start of that period, or 25 percent of its revenue-producing business, or includes its principal trade name, core technology or principal customer contracts, but not a sale of inventory or replacement of equipment in the ordinary course of business.
>
> **Sec. 2. Election.** (a) A cooperative becomes a locked cooperative by adopting articles under Sec. 1(a), approved (1) at formation, by all of its founding members; or (2) otherwise, by at least two-thirds of its members and not less than the vote required to revoke cooperative status, and by the holder of any security interest in substantially all of its assets. (b) A member who voted against an election under subsection (a)(2) may withdraw within 90 days and shall be paid the balance of the member's capital account on the cooperative's ordinary schedule. (c) The Secretary of State shall show locked status in the public record of the entity, and the cooperative shall file a financing statement giving notice of it.
>
> **Sec. 3. Status.** (a) A locked cooperative may end its locked status only by a lock-preserving transaction. (b) Any provision of law allowing members to revoke cooperative status, including [the state's worker-cooperative revocation section], does not apply to a locked cooperative. (c) Without the written consent of the lock-holder, no amendment may change the articles' lock provisions, named fund or lock-holder, except a replacement under Sec. 5(d). (d) The lock share and the restrictions in the articles are reasonable restrictions and are not void as restraints on alienation. They bind every successor, receiver, assignee, trustee and transferee with notice.
>
> **Sec. 4. Restricted transactions.** Without the written consent of the lock-holder, a locked cooperative, and any entity it controls, shall not (1) merge, consolidate, convert, domesticate or transfer to another jurisdiction, except in a lock-preserving transaction or into an entity that remains a locked cooperative; or (2) make a covered transfer.
>
> **Sec. 5. Duties of the lock-holder.** (a) The lock-holder shall consent to a lock-preserving transaction, to a covered transfer of assets no longer needed in the business at fair value, and to a transfer of part of the business to a new locked cooperative. (b) The lock-holder shall not consent to any transaction by which all or substantially all of the business, or its principal trade name, core technology or principal customer contracts, would be held by anyone other than a locked cooperative. (c) A request under subsection (a) not answered within 30 days is approved. A member may seek expedited review of a refusal, and the court shall award costs against a lock-holder whose refusal was unreasonable. (d) Members may replace the lock-holder by a two-thirds vote with another approved lock-holder, except while a request is pending before it. (e) A lock-holder shall not act on a transaction in which it, or a fund it controls, is a party or would receive assets.
>
> **Sec. 6. Dissolution.** (a) On the dissolution of a locked cooperative, whether voluntary or by order of a court, the assets remaining after payment of creditors and of members' capital account balances shall be distributed to the cooperative development fund named in its articles, or, if that fund no longer exists, to a fund named by the lock-holder that most nearly serves the same purpose, or, failing that, by the Attorney General. (b) Allocations to members' capital accounts in the 24 months before dissolution, in excess of the average annual allocation in the 36 months before that period, are paid to the fund and not to the members. (c) No court shall order any other distribution of those assets, except payment from a member's own capital account.
>
> **Sec. 7. Transfers outside bankruptcy.** (a) Notwithstanding [the state's enactment of UCC Article 9], any law governing mortgages, execution, tax sales, receiverships or assignments for the benefit of creditors, and any choice of law in an agreement, a covered transfer of a locked cooperative's property by a secured party, mortgagee, officer executing a judgment, taxing authority, receiver or assignee shall be made as follows: (1) for 90 days after notice of the sale, the business may be sold as a whole only to a locked cooperative, a cooperative federation or a cooperative formed by its workers, at a price not less than the value of its assets sold separately as found by an independent appraiser; (2) thereafter its assets may be sold separately, but no buyer, with anyone financially related to it, may acquire all or substantially all of the business; and (3) a buyer under paragraph (2) that employs more than one-third of the cooperative's members within two years shall pay the cooperative's creditors the difference between the price paid and the value of the business as a going concern. (b) A disposition made in accordance with this section is commercially reasonable. (c) The cooperative development fund shall pay each involuntary creditor the amount, if any, by which this section reduced its recovery. (d) This section does not apply to a secured party that did not consent to the election and whose interest was perfected before the cooperative became locked.
>
> **Sec. 8. Enforcement and remedies.** (a) The lock-holder, any member, any other locked cooperative, [the bank] and the Attorney General may bring an action to enforce this Act. A court shall award costs against a party whose action is frivolous. (b) The Secretary of State shall not accept a filing that would effect a transaction prohibited by Sec. 3 or 4. (c) A transfer in violation of Sec. 4 may be set aside against a transferee with notice. Against a transferee that gave fair value without notice, it stands, and the cooperative's proceeds from it, and any loss to the cooperative, are recoverable for the fund from the persons who caused it. (d) If any conversion or transfer is completed despite this Act, the value of the business in excess of its debts and its members' capital account balances, as found by the court, is held in trust for the fund named in its articles.
>
> **Sec. 9. Lock-holders.** The Attorney General shall approve as lock-holders the organizations on [the bank's] list of approved lock-holders, and any cooperative federation or nonprofit that meets published standards of independence, funding and succession equivalent to that list's.
>
> **Sec. 10. Members' liability for wages.** (a) Notwithstanding [the state's laws making shareholders or members of a corporation, cooperative or limited liability company personally liable for its wages], a member of a locked cooperative that is covered by [the insurance pool established under the bank's authorizing act] on the date the unpaid services were performed is not personally liable, by reason of being a member, (1) to another member, or (2) to anyone else, to the extent of wages the pool has paid. (b) Subsection (a) does not apply to wages for services performed more than five business days after the cooperative first failed to pay wages when due, unless it was then operating under a filing for relief under title 11 of the United States Code or a payroll loan from [the fund]. (c) This section does not limit the liability of any person (1) who, at any time from 90 days before the cooperative first failed to pay wages when due, served as a director, officer or managing agent, approved payroll, signed for its payment or directed employees to continue work after wages were unpaid; or (2) who received wages in full, or any distribution, allocation paid in cash, redemption or other payment from the cooperative, for a period in which other employees' wages were due and unpaid. (d) This section does not affect liability under federal law.
## What it costs, and who objects
- **Members give up an exit the law otherwise gives them.** Libertarian and conservative critics object that the model law overrides a state's own rule, like the Massachusetts two-thirds vote, for people who may join the co-op decades after the choice was made. Founders choose it unanimously. In an existing business, those who vote no can leave with their savings. Anyone who joins later is told in writing before becoming a member. That narrows the objection but doesn't answer it: "forever" still binds people who weren't there, which is why the lock's [costs](the-lock.md#what-the-lock-costs) and the Italian alternative stay open questions.
- **Members who vote no get their savings, not a share of the business.** A libertarian critic would pay them what their share would be worth if the business were sold unlocked. The model law doesn't, because that value is exactly what the lock keeps from being cashed out. So some dissenters will feel they lost something, and they may take it to court.
- **The lock-holder has real power over big sales.** The duties above limit it, but a member who wants to sell part of the business still has to convince an outside body, or a judge.
- **Lenders get less if a co-op fails.** Fewer buyers means lower prices, and a 90-day wait. Lenders will charge for that, and the bank's published recovery rates show how much.
## What this doesn't solve
- **Only adopting states.** A co-op organized elsewhere relies on its charter and lock share alone, and even a co-op organized in an adopting state can lose property sold under another state's law.
- **A legislature can repeal it.** Nothing here can stop a later legislature changing its own corporate law, and most states reserve the power to amend the rules for companies they charter. A repeal would leave the charter, the lock share and the federal rules.
- **Bankruptcy still needs Congress.** State law can't override federal bankruptcy law. The [bankruptcy amendment](bankruptcy.md) is still needed, and this law only covers the sales that happen outside bankruptcy.
- **It hasn't been tested.** No US state has a lock like this for a business. The closest are charity law, which already keeps a charity's assets dedicated to its purpose, and Britain's community interest companies ([The Lock](the-lock.md#why-forever-isnt-as-strange-as-it-sounds)). How courts would read it, and whether a dissenter could win a claim that locking took their property, is unknown.
@@ -0,0 +1,44 @@
---
title: Open Problems
section: Guardrails
order: 140
summary: An honest list of what isn't solved yet.
---
# Open Problems
An honest list of what the model hasn't solved yet, most dangerous first.
1. **Adverse selection on founders.** *(The most dangerous.)* The lock removes the exit, and profits are shared with every member. In the success case a co-op founder ends up with somewhere between about a seventh and a fortieth of what a conventional owner of the same firm would ([The Founder Problem](founder-problem.md)). For founders without wealth the gap in what the bet is *worth* is smaller, but at the top it's decisive. If the strongest founders go conventional, the bank's portfolio is the rejected pool, and the test fails for reasons that say nothing about co-ops. The pilot can at least tell how much of a result is down to founders: [Co-ops or Their Founders?](who-founds.md) measures founders on both sides from government records, and the verdict has to say how much their backgrounds explain. That doesn't change pass or fail, because if the lock keeps strong founders away, that's part of what the lock does. Still open: nothing yet makes the deal better for the founders the model most needs, and with about 400 co-ops, "too few to tell" is a likely answer.
2. **The horizon problem.** *(Mostly answered.)* Members near retirement have little reason to vote for investments whose payoff arrives after they leave. The evidence from France and Italy finds no underinvestment where co-ops must keep a reserve, and [Investing for the Long Run](the-long-run.md) closes the main routes around this model's reserve: pay above the going rate, sales of assets, development spending, high pay that turns into losses, and worn-out equipment still in use. A warning light shows when a co-op stops replacing its equipment, and big bets get an independent opinion before members vote. Still open: nothing forces a co-op to maintain what it has, a bad bet that fails after its backers leave falls on the people who came after, and it's unsettled whether 30% is enough and whether the account rate is right. Members' accounts now pay back within three to seven years ([Member Capital Accounts](capital-accounts.md#why-not-pay-it-out-when-you-leave)), so they help less with long-lived investments than accounts held until members leave. Letting members sell their places to incoming workers was rejected, because that is how the plywood co-ops ended up sold.
3. **Members' savings sit inside their employer.** *(Partly answered.)* [Member capital accounts](capital-accounts.md) are capped at a year's pay, protected by a solvency test, and now insured by a pool the co-ops pay for ([Protecting Members' Savings](savings-guarantee.md)). It covers final wages, former members' payouts and a protected slice of current accounts. Premiums are now estimated from Europe's wage guarantee funds and Census closure rates for small firms like co-ops: about 0.55% of payroll for final wages and notice pay, and 3% to 6.5% a year of covered savings depending on the co-op's age, which takes most or all of the interest on a protected slice. The wage rate assumes a failed co-op owes about five of the eight weeks, counting pay in place of notice, which nobody has measured. Notice pay and severance from overlapping federal, state and bylaw rules don't add up; a worker gets the largest of each, but the pool doesn't cover severance, and a long-serving worker's severance is mostly lost in a failure. The reserve target is six extra years' worth of claims while the pool is young, and limits for new additions fall when the reserve falls behind its planned path. If a recession hit in the pilot's fifth year, the pool would pay roughly 45 to 60 cents on the dollar of savings claims, so in its early years it's a partial promise, and cover for current members' savings waits until the pool has its own track record. Still open: the pool has no government backing, so a worse recession could outrun it; the estimates rest on rough data, not co-ops; the reserve takes about fifteen years to build; and it needs a federal charter to avoid fifty states' insurance laws. Also open: an account paid out when a member leaves would probably be a pension plan under federal law, which would forbid the co-op from using the money. So accounts now pay each year's credit back on a fixed schedule of three to seven years, whether or not the member has left. That's designed to stay outside pension law, but it isn't settled until the Labor Department answers, and federal appeals courts weigh programs like it differently. Because nobody loses their account for leaving, much of what's paid goes to people who've left, which weakens the case. So co-ops where more than a third of payouts go to people no longer working there shorten the wait, then stop making new credits, and credits taxed only when paid wait for the Department ([Member Capital Accounts](capital-accounts.md#why-not-pay-it-out-when-you-leave)). In high-turnover work, that almost always means no accounts, so the lowest-paid members lose the savings the model promises them. An ordinary retirement plan outside the business, with the co-op adding 3% of wages for everyone and, from 2027, a federal match for low earners, makes up part of that ([Member Capital Accounts](capital-accounts.md#saving-for-retirement)). It gives up the trust, disclosure and fiduciary rules that pension law would bring, which the model only partly replaces, and it leaves the co-op less of its members' money to invest for the long run. State wage laws are a second risk: in some states, California likely among them, a credit in a member's account could count as wages, which can't be held back, charged with losses or paused. Co-ops in those states don't use accounts until the state changes its law ([Owners Are Still Workers](labor-law.md#is-the-profit-share-pay)), so members there build no savings in the co-op. A third: account credits look like the notes the Supreme Court has held to be securities, so the model keeps members from putting money in and asks the SEC for an answer ([Member Capital Accounts](capital-accounts.md#are-accounts-securities)). If the answer is no, the nearest exemption means calling credits pay, which works against the model's positions on tax, pension and wage law.
4. **Is the lock too rigid?** A co-op that should be sold (a better operator exists, the members want out) can only go to another co-op. If the co-op sector is small, that buyer pool is tiny, and viable firms may be liquidated instead of rescued. The federation as buyer of last resort is a partial answer. Critics on the right add a sharper version: a lock that outlasts a repaid loan by generations is a permanent restriction on selling property, bought with one loan, and it binds members who never agreed to it. The lock is now built to hold in layers, including an independent lock share and rules against selling the business piece by piece ([The Lock](the-lock.md#how-the-lock-is-made-to-hold)). Its biggest legal gap is bankruptcy: federal bankruptcy law generally overrides limits on selling a bankrupt company's property, so the lock only holds in bankruptcy if Congress amends that law. [When a Locked Co-op Goes Bankrupt](bankruptcy.md) drafts that amendment. It's modeled on how bankruptcy law already treats charities. Only co-ops can buy a failed co-op whole, its workers can keep it running and bid with the wages they're owed, and creditors who never chose to lend are paid the lock's cost from the federation fund, which the co-op that buys the business repays. Whoever carries on the business, including members who keep it through a reorganization, takes on injuries from the old co-op's products, its warranties and household customers' deposits. When members keep a co-op over creditors' objections, the value faces a market test, though a weak one, since a conventional offeror can never win. That cost is usually nothing but can be large, and nobody has measured how often. The bank lends toward a workers' bid only up to what the assets would bring sold off piece by piece, so the rest has to come from the workers' claims, the insurance pool or patient money from the federation, and a failed firm often won't find it. It still needs Congress, a later Congress can repeal it, and while the co-op sector is small, some viable businesses will be broken up for lack of a co-op buyer. Italy offers a softer model: a co-op there can convert into an ordinary company, but its accumulated reserves go to the co-op development fund, not to the members ([Has This Been Tried?](precedents.md#italys-co-op-funds-coopfond-and-the-marcora-law)). That would keep the part of the lock that stops members cashing out what earlier generations built, and drop the part that stops a firm from ever changing hands.
5. **Scale.** *(Partly answered.)* Even a total success at pilot scale leaves most workers in conventional firms. The labor-market effect needs a sector large enough to move wages, and nobody knows how long growth to that size takes, if it happens at all. That's the price of not forcing it. [After a Pass: How Big, How Fast](scale.md) works through the numbers: even growing at twice the recent pace every year, co-ops would take about 19 years to reach 1% of today's private jobs. Growth after a pass comes in capped stages sized to demand the reviewers certify, and the labor-market effect gets its own test, by lottery, in local markets where co-ops could become a large share of one kind of work. Still open: the closest evidence cuts both ways (big retailers paying more didn't lift other employers' pay; hospitals hiring nurses did), and co-ops may never become a big enough share anywhere to find out. A first-draft power calculation from public pay records ([The numbers, in a first draft](scale.md#the-numbers-in-a-first-draft)) says the local test could only see an effect of about 5% of pay, bigger than the union evidence suggests a 10% co-op share could produce. So on public numbers the test fails its own rule and wouldn't run. Unless better records change that, the labor-market effect may stay untested. And as a way to raise pay across a whole kind of work, wage boards are faster, more direct and cheaper for the government's budget, though their effect on jobs is disputed ([Compared with wage boards and unions](scale.md#compared-with-wage-boards-and-unions)). The labor-market effect isn't a reason to prefer co-ops for that job.
6. **Political durability.** *(Partly answered.)* Gradual construction invites gradual repeal. [Making It Last](durability.md) builds the bank to survive a hostile government: the lending standards and the test are fixed in law, board vacancies can't freeze lending, the verdict says "not tested" rather than "failed" if the bank turns away applicants who meet its own standards (every rejection is re-scored, the line is more than 1 in 10 clear passes turned away, and the result has to be one those applicants could have changed; see [the line](durability.md#when-rejections-mean-not-tested)), and repeal can't unlock the co-ops. Still open: after the Supreme Court's 2026 ruling any president can replace the bank's board; money not yet lent can be taken back in a budget bill; the lock's bankruptcy protection depends on a federal law that can be repealed; and at pilot scale the sector has no real constituency. The 1-in-10 line is a judgment call with no benchmark behind it. The bank's law lets applicants and borrowers sue to enforce its deadlines and signed loans ([Who can go to court](durability.md#who-can-go-to-court)), but that's untested, lawsuits are slow, and if the Justice Department won't defend the law itself, the people left to defend it may not be allowed to. The Constitution probably requires that the board have the last word on each loan, so the board can overrule its staff, only in writing, by named vote and in public ([Who has the last word](durability.md#who-has-the-last-word)). Whether the credit officers can keep protection from being fired after *Slaughter* is unsettled, and a president who empties the board hands its power to the Treasury Secretary.
7. **Growth and dilution.** *(Partly answered.)* Softened by the tenure ramp, not eliminated, and federal tax law softens it less than the founding plan expected: by default, the part of a share new members haven't reached goes to the reserve, not to other members ([How a Locked Co-op Is Taxed](taxes.md#the-tenure-ramp)). In steady state, an equal split per head still means a marginal hire must generate the average surplus to break even for incumbents. Capital-intensive expansion into lower-margin lines will face internal resistance conventional firms don't have. [When Growing Means Sharing](growth.md) makes Mondragon's answer the standard path: a lower-margin line becomes its own locked co-op, funded from the parent's reserve as a finite, voteless investment, under rules that stop it being used to push existing workers out or move money through insider deals. Still open: growth that can't be split off still dilutes, spin-offs create richer and poorer co-ops (the groups can pool profits, but don't have to), and the evidence comes from co-ops that could hire non-members, so it probably understates the problem under the lock. Members who share profit also have a reason to hire people like themselves. The pilot tests co-ops' hiring as a group with fake paired applications ([Testing hiring directly](growth.md#5-measuring-it)). But that can only catch co-ops being much worse than ordinary employers, conventional firms aren't tested alongside them, and hiring through members' friends and family is only counted, not tested. And the measure of whether co-ops grow more slowly than similar firms is too noisy, at pilot scale, to find anything but a very large gap ([Measuring it](growth.md#5-measuring-it)).
8. **Who decides which sectors are "essential"?** *(Partly answered.)* Subsidy authority is price-setting power over a large chunk of the labor market, and every low-margin industry will suddenly discover it is Essential. [Who Decides What's Essential](essential-work.md) takes the choice away from people. A fixed formula decides: work qualifies only if public programs already pay for most of it and it pays badly. The money goes to workers by the hour, measured from records like Medicaid's visit logs, and states whose rates fall behind inflation lose federal Medicaid money in step. The data sources are now named: federal payment records for public money, and Census revenue, with new Census questions on a deadline and rules so status can't flip on noise. Still open: counted properly, hospitals are near the line, which would add their low-paid jobs and a great deal of cost; child care's status is undetermined until the new data exists; other work mostly paid for by families doesn't qualify; employers and owners may still capture some of the money; workers without work authorization get nothing and may be made worse off; and at a first-draft $3 an hour it costs about $16 billion to $20 billion a year for home care aides and nursing assistants alone, more if hospitals qualify ([Paid to workers, by the hour](essential-work.md#paid-to-workers-by-the-hour)).
9. **The 1/N problem.** *(Mostly answered.)* Peer monitoring works at 30 people; at 10,000 your extra effort raises the pool by a rounding error. The answer is culture and structure: co-ops really do push out free riders, and the federation model keeps units small enough for monitoring to bite while pooling risk above them. [Does Sharing the Profit Mean Less Effort?](free-riding.md) turns that into rules: small teams with their own numbers, team bonuses whose formula members set in advance, and a fair, fast dismissal process with protections against retaliation and discrimination. It sets first-draft numbers: all bonuses together at most 10% of pay and no one's more than 15% of their own, and a dismissal needs four of five members drawn by lot, with an outside panel from other co-ops for small co-ops. Still open: the evidence comes from conventional firms with profit sharing, not co-ops, and a small shared stake may even make people less willing to speak up; the pilot tracks value added per hour as co-ops grow to find out.
10. **The pilot's own weak points.** [The pilot](pilot-spec.md) puts the burden of proof on the co-ops, which means roughly a 1-in-10 chance of shutting down a pilot that was actually working. It tests individual co-ops, not the [federation](federation.md) support network Mondragon relied on. Its lotteries test help after the loan and, since the pre-launch lottery was added, help before it ([Help Before the First Loan](before-launch.md)), but only among groups that come forward, not outreach to workers who never think of starting a co-op. The pre-launch help now has a cost ceiling set in advance, $400,000 of total public cost per extra co-op still open at year four, but there's no good benchmark for it, and the lottery is small enough that "not settled" is a likely verdict ([The cost ceiling](before-launch.md#the-cost-ceiling)). Founders now put in a small stake they can pay in unpaid work, but founders who can't afford months of unpaid work still face a barrier. Its comparison group (small businesses with government-backed loans) is biased in directions that partly cancel out, by amounts nobody knows. And market-rate lending based on collateral may mostly reach founders who already have some wealth. Approval rates by race, gender and wealth are published so that shows up, but publishing a gap doesn't close it.
14. **Drift toward the safest co-ops.** *(Partly answered.)* A bank that lends only to co-ops can still lend mostly to the safe ones, like consultancies started by people with savings, as America's co-op bank did. [Who the Bank Lends To](who-gets-loans.md) sets aside $90 million of the loan money for co-ops in lower-paid work, half of it for the lowest-paid industries, which goes back to the general pool only if the reviewers certify the bank handled those applications fairly. Results are reported for that group and for first-time owners without much wealth, and the verdict has to say which kinds of co-ops it covers. Still open: the set-aside makes money available but can't make groups apply; within each half it can be used mostly on the better-paid end; "lower-paid" is judged by industry, which leaves out poorly paid co-ops in well-paid industries and can be gamed by how a co-op describes its work; first-time owners without wealth get reporting, not money; and in work whose prices are set by Medicaid or contractors, a failure may say more about the price than about co-ops. Even measuring who gets loans needs its own rule: lending rules bar asking applicants' race and sex, and the federal exception for small-business lenders won't reach a bank this small, so the bank's law has to require the question, and a later Congress could drop it ([Asking needs its own legal basis](who-gets-loans.md#treated-the-same)).
15. **Taxes.** *(Partly answered.)* A locked co-op is a corporation that pays tax once on members' profit shares, under the part of the tax code written for co-ops. [How a Locked Co-op Is Taxed](taxes.md) fits the model to it: profit is shared per hour worked, workers who aren't members get a share too, wages reach the going rate before any profit is shared, and charges come out of the current year's share before older, already-taxed balances. Still open: nobody has ruled that a co-op with an outside lock-holder and a reserve that goes to the federation counts as a co-op for tax purposes; whether profit shares owe payroll tax is unsettled; the ramp no longer shields senior members from new hires unless it's paid as wages; workers without work authorization can't be members; the kept reserve pays 21% even though nobody owns it; members can't take the 20% deduction owners of partnerships and S corporations get, so the co-op comes out ahead of partnerships only if profit shares escape payroll tax ([How this compares](taxes.md#how-this-compares-with-other-businesses)); the return on members' accounts probably can't be deducted, so account holders' returns are credited after the co-op's tax ([Interest on accounts](taxes.md#interest-on-accounts)); following the deferred-pay rules for lower-income members' untaxed credits could be read as conceding that the credits are pay; and a member who loses their account when a co-op fails can probably deduct only $3,000 a year of it, after being taxed on it in full, until Congress refunds that tax.
11. **Federal or state?** *(Mostly answered.)* The test stays federal, because one bank with one plan makes one clean experiment. Any state can copy the bank under the same published rules, reported separately, and a model state law protects the lock for co-ops that choose it ([The Lock in State Law](lock-state-law.md); [Making It Last](durability.md#federal-test-state-copies)). No state has adopted anything like it, and a co-op organized in a state that hasn't relies on its charter alone. Still open: whether a few state lenders sharing one plan would have been the stronger experiment after all, since the most durable public bank in America, North Dakota's, is a state bank.
12. **Voluntary buyouts: in or out?** *(Answered.)* In, on a separate track: $100 million of its own, healthy firms only, a willing seller, no new tax break, a capped price the seller shares the risk on, a vote of the whole workforce, and its own test of money and pay, which counts the tax sellers put off under an old tax break ([The tax break that already exists](buyouts.md#the-tax-break-that-already-exists)), so it can't flatter the startup result ([When an Owner Wants to Sell to the Workers](buyouts.md)). Still open: whether a price set by the market is fair to workers who pay it out of their own future work, and whether about 60 deals can show more than that the loans get repaid. The comparisons are now set out: SBA-financed purchases, ESOP sales and deals that fell through, with a loss rate fixed by formula in advance ([Who buyouts are compared against](buyouts.md#who-buyouts-are-compared-against)). With so few deals, the loss test can catch a track that does much worse than SBA lending, but it can't show that buyouts do better. Why co-ops can't simply use the SBA is now spelled out: its rules ask a co-op, unlike an ESOP, for cash up front and one member's personal guarantee. The bank's deals do without both, so they rest on the seller's paid-last share and a coverage test on past cash flow ([Why not just use the SBA?](buyouts.md#why-not-just-use-the-sba)). Under the SBA's rules from October 2026, those two rules are nearly the whole gap, and both are SBA policy it could change itself. If it does, or a small fund covers the cash stake, much of the track may not be needed. Whether that's enough in place of cash and guarantees is something only the loss record will show. State programs paid for by Treasury and USDA's rural guarantees already allow co-op buyouts in pieces. The track uses them, but can't count them as private money or use them to hide losses ([Other public help](buyouts.md#other-public-help-that-already-exists)). How often private lenders will really take an equal share is unknown, so deals the bank makes alone are capped at a third of the money. The help each sale needs, the workers' adviser, checks on deals that fall through and help after the sale, costs the public roughly $110,000 to $240,000 per closed deal, about $12 million in all. That comes out of the same margin the money test measures, so the track passes only if losses stay low ([What the help costs](buyouts.md#what-the-help-costs)).
13. **Work where people don't stay long.** *(Partly answered.)* In home care, most workers leave within a year or two. Membership is now automatic after a short probation, the buy-in comes out of profit shares rather than pay, and pushing people out before they'd join counts against the co-op ([When People Don't Stay Long](high-turnover.md)). Still open: with a five-to-ten-year profit share ramp, only a small minority of care workers ever reach a full share, so the people who stay collect much of the profit the others helped make. A much shorter ramp for low-margin, high-turnover co-ops would fix that, but it would go against the founding plan's range and weaken the ramp's protection against under-hiring. Also open: whether default members take part in governance, and whether Social Security counts a profit share as earned income for SSI, which decides whether members on SSI lose more by joining. SSI may also count an account credit as income when it's made. Until Social Security answers, members on SSI get their share all in cash and join only if they say yes ([How a Locked Co-op Is Taxed](taxes.md#benefits)), and workers who have only an IRS taxpayer number still can't be members. How labor law treats worker-owners now has its own page ([Owners Are Still Workers](labor-law.md)). Federal law makes the people who run payroll personally liable for unpaid minimum wages, and New York appears to make every co-op member liable for all unpaid wages, though no court has tested that. The model stops ordinary work after a missed payroll and asks states to narrow the rule ([When wages go unpaid](labor-law.md#when-wages-go-unpaid-members-can-owe-them)), but until a state does, members there carry the risk, and elected officers carry it everywhere unless insurance covers it. Federal labor law probably doesn't protect members' own organizing, so the charter does, and fixing the law takes a separate bill that may never pass. Some states leave members out of unemployment insurance, and whether founders' unpaid work before the co-op forms is outside wage law still needs the Labor Department's answer. A federal rule due in 2030 requires 80% of certain Medicaid home care payments to go to care workers' pay. Co-ops meet it from wages alone and pay any shortfall as wages, but in that work it leaves little room for profit shares or a reserve, and whether the rule survives is unknown ([When Medicaid decides how much goes to pay](high-turnover.md#when-medicaid-decides-how-much-goes-to-pay)).
16. **What the rules cost a small co-op.** *(Partly answered.)* Every check the model adds costs a small co-op money. [What the Rules Cost a Small Co-op](compliance.md) scales checking to size: a yearly review and a fixed list of tests instead of an audit for small co-ops, audits by lot and on warning signs, and a lock-holder review every two years. Even so, the model's own estimate is 1% to 2.5% of a small co-op's sales, which could be a large part of the profit in cleaning or home care. Whether lighter checking lets more cheating through is something the audits by lot will show. Italy's "false co-ops" are the warning.
*Not problems here, because nobody's company is forcibly bought:* valuing every firm in the economy, owners stripping value before a takeover, foreign capital flight, and years of payouts to former owners. The contractor boundary isn't an economy-wide problem either. Its co-op version, hiring non-members to dodge membership, is handled by [the lock](the-lock.md)'s membership requirement.
+94
View File
@@ -0,0 +1,94 @@
---
title: Who Sets the Pay
section: The Co-op
order: 22
summary: Salaries in a co-op are set by its members, so the model guards against two opposite failures, a majority paying itself a bigger slice and pay squeezed so flat that higher-paid people leave, with open pay bands, a pay ratio each co-op chooses, and a second vote that protects the lower-paid.
---
# Who Sets the Pay
In this model, base salaries still exist, and the co-op sets them. Hard jobs and in-demand jobs pay more ([Worker Ownership](worker-ownership.md)). There's no legal cap on pay.
That makes pay the one big lever the profit split doesn't control. Profit is divided by fixed rules: 30% to the reserve, then shares by hours worked, times each member's share weight, which grows with tenure until it's full ([Member Capital Accounts](capital-accounts.md)). Salaries are whatever the members decide. So a co-op's pay decisions can quietly undo the rest of the design, in two opposite ways.
## Two ways pay goes wrong
**Too much at the top.** A group of members who together make up a majority, say managers and skilled staff, can vote themselves higher pay. That money comes out of profit that would otherwise have been shared among all members. The model already takes part of it back. The reserve gets 30% of any pay above the going rate for the job, meaning the typical local pay for that kind of work ([Investing for the Long Run](the-long-run.md#the-rules)). But the other 70% still goes to whoever voted for it.
**Too flat, so higher-paid people leave.** Gabriel Burdín followed a random sample of Uruguayan workers through social security records from 1997 to 2010 ([Burdín, *Economic Journal*, 2016](https://gwern.net/doc/economics/2016-burdin.pdf)). He found:
- **Worker-run firms paid more at the bottom and less at the top.** Among the lower-paid (the 20th percentile), working in a co-op was linked with about 18% higher pay than in a conventional firm. Among the higher-paid (the 80th percentile), about 4% lower. That's a comparison across people, not proof that the co-op caused the difference.
- **Their higher-paid members quit more.** Members paid above their own co-op's median were 3.7 times as likely to quit as those paid below it.
- **Less squeezing meant fewer quits.** The effect was stronger in co-ops that squeezed pay more, and weaker among founding members.
That's one study, in one country. It measured only one of the two failures, since insiders quietly overpaying themselves is much harder to see in the data. So it doesn't show which failure is bigger. But it does show that the second one is real.
**Mondragon keeps a hard cap.** It started with top pay at three times the lowest, raised that to four and a half in the 1970s when it set up its own welfare system, and to six in 1988 in response to tax reforms. The head of its outreach program describes the cap as a balance between avoiding a brain drain and keeping a sense of fairness ([Co-operative News, 2025](https://www.thenews.coop/co-operative-approaches-to-pay/)).
## The rules
The model doesn't set anyone's pay. It makes sure pay is decided in the open, in the right order, and that the lower-paid can't simply be outvoted.
### Pay bands, not personal votes
- **Every job has a pay band**, a range from lowest to highest. Members approve the bands, as part of the pay policy.
- **Managers place people within the bands**, and can give raises there, the way any employer does. Bonuses follow formulas the members approve in advance, within limits ([Does Sharing the Profit Mean Less Effort?](free-riding.md#team-bonuses-the-members-design)). Nobody's personal salary goes to a vote of the whole co-op.
- **Offers can run for up to three years.** A salary agreed within an approved band holds for the length of the offer, even if the band changes later. So a co-op can hire someone with a firm offer.
### Everyone sees the bands
- **Every worker, member or not, sees every band** next to the going rate for that job. Where a job has fewer than three people in it, only the band is shown, not what each person earns. Managers' actual pay is always shown.
- **Job ads list the pay band.** Several states already require this.
- **Workers can always talk about their pay.** Federal labor law already protects that for most workers, and a co-op can't ask anyone to keep their pay secret.
- **The public sees the totals.** Each year the co-op publishes its pay ratio, how much of its total pay was above the going rate, and pay by job, gender and race in summary form.
### Each co-op chooses its ratio
- **The bylaws must set a highest-to-lowest pay ratio.** There's no ready-made number. The founders have to pick one, and the founding documents show Mondragon's six to one for reference.
- **The ratio counts everything and everyone.** It's measured on total pay per full-time hour, including bonuses and benefits valued at cost. It includes people working through agencies, contractors and related companies. Moving the cleaning to an agency doesn't raise the bottom of the scale on paper.
### Two majorities, not one
A majority vote can't protect a minority from the majority. So some pay decisions need **two majorities**: a majority of all members, and a majority of the members in the lower-paid half. They are:
- changing the pay ratio;
- any band whose top is above the 90th percentile of pay for that job in the co-op's own area, for remote workers too, or above three times the co-op's own median pay. Where the survey gives no exact figure at the top, the highest figure it gives is used;
- any change that raises bands for jobs held by more than half the members.
These votes are by secret ballot, with at least 30 days' notice. That makes both kinds of grab harder: a skilled majority can't vote itself a raise over the heads of the lower-paid, and a scarce specialist can still be paid what it takes, if both groups agree.
### Nobody votes alone on their own pay
- **The board approves managers' bands and the chief executive's pay.** Managers who sit on the board step out of those votes.
- **No two people can set each other's pay.**
- **Board pay, if any, is set by the members.**
- **Founders' pay** follows the existing rule: the founding bylaws can set it for at most five years, then the members take over ([The Founder Problem](founder-problem.md#the-tools)).
- **Pay is added up per person.** Pay from more than one role at the co-op, fees paid to a company the person owns, and pay from a related company all count together.
### Unions fit in
A union can bargain for any group of workers, members or not. For a minority that thinks it's being underpaid, collective bargaining is the standard protection, and it works inside a co-op too ([When People Don't Stay Long](high-turnover.md#unions-stay)).
## Why not a fixed cap
A legal cap on the ratio, like Mondragon's six to one, would be simpler. The model doesn't impose one, for two reasons:
- **Different businesses need different ratios.** A cleaning co-op and an engineering co-op don't hire in the same job market, and a cap that suits one would strangle the other.
- **Pay squeezed too flat has a real cost.** The large study above found higher-paid members leaving co-ops that squeezed pay hardest. A national cap would make every co-op take that risk.
What the rules do instead is make every co-op choose a ratio, in the open, and make high pay something the lower-paid have to agree to.
## What the pilot measures
The [pilot](pilot-spec.md#what-gets-measured) publishes, alongside the gap between top and bottom pay:
- **the share of pay above the going rate**, so any drift toward insiders paying themselves shows;
- **how often higher-paid members leave**, compared with the matched conventional firms, so a brain drain shows too;
- **pay gaps by gender and race within the same job.**
## What this doesn't solve
- **The going rate is a rough measure.** A government wage survey can't tell a strong engineer from an average one, and it carries the same pay gaps as the rest of the economy. It's a yardstick for when to ask questions, not a measure of what work is worth.
- **Two majorities are harder to reach.** Some fair raises will stall. That's the price of making sure the lower-paid can't be outvoted on pay.
- **Open bands still reveal a lot in a small co-op.** Some people will find that uncomfortable, and some good hires may not want it.
- **Nothing here makes a co-op pay its higher-paid people enough.** The rules make it easy to see when it doesn't. Whether members vote to pay what it takes to keep them is exactly what the pilot will show.
@@ -0,0 +1,107 @@
---
title: Guarding the Pilot
section: Growing the Sector
order: 66
summary: The rules that stop people from gaming the pilot with sham co-ops, hidden owners, split-up borrowers, zombie firms, or a rigged review.
---
# Guarding the Pilot
Any program that hands out money gets gamed. Venezuela's co-op boom produced tens of thousands of co-ops that existed mainly on paper ([YES! Magazine, 2007](https://www.yesmagazine.org/issue/latin-america/2007/05/12/venezuelas-co-op-boom)). Uruguay's FONDES sent nearly half its money to three politically connected firms ([El Observador, 2015](https://www.elobservador.com.uy/nota/mitad-de-dinero-del-fondes-fue-para-empresas-companeras--201572500)).
This page lists the ways someone could cheat [the pilot](pilot-spec.md), or sabotage it, and the rule that blocks each one. Most of these tricks don't require breaking any law. They only need a loophole, which makes them the likeliest threats. So the rules are written to close the loopholes.
## The hidden boss
**The trick.** A founder keeps the building, equipment, brand and software in a separate company they own. They sign up three friendly "members" and give the co-op a management contract or franchise deal with that company. The co-op borrows $3 million, and the money flows back to the founder as rent, fees and royalties. On paper it's a co-op. In practice it's the founder's business, funded by the public.
**The rules:**
- **Every deal with an insider is disclosed.** Any contract with a founder, manager, member's family, or a company they own is reported to the bank and checked each year to confirm it's priced like a deal between strangers. Overpayments are treated as a breach of the loan. After the loan is repaid, the same check continues as part of the lock, along with the two rules below on ending contracts and buying key assets ([Deals with insiders](the-lock.md#deals-with-insiders)).
- **Members can fire management.** Any management or franchise contract can be ended by a members' vote on short notice. A contract the members can't get out of is a boss.
- **The business owns what it needs to operate, or can buy it.** If key assets are leased from an insider, the bank gets a claim on them or the co-op gets an option to buy them at a fair price. That way the co-op can't be held hostage, and the bank's collateral is real.
- **Membership is real.** Probation lasts at most 12 months, counted across rehiring, and membership is automatic when it ends unless the worker opts out through the lock-holder. Members must do at least half the work hours in the co-op's second year and three-quarters from its third, counting hours worked through agencies or contractors. Pushing people out before they'd join is tracked and counts against the co-op ([When People Don't Stay Long](high-turnover.md)). All of this is checked every year. A co-op that falls short counts as failed in the [pilot's survival measure](pilot-spec.md#what-gets-measured).
- **Lying costs something.** False statements on a loan application mean immediate repayment, and the people who made them are personally liable.
## Draining it through "investors"
**The trick.** [Outside investors in a co-op](capital.md) get no vote and a claim that expires. But nothing so far limits how big or long that claim can be. A friendly investor could take 90% of the profits for 40 years, and the co-op would be a shell.
**The rules, for pilot borrowers:**
- **The bank gets paid first.** Any outside profit-sharing claim ranks behind the bank's loan.
- **The bank must approve new claims.** Claims last at most 15 years.
- **Insiders' claims count as insider deals.** An investment by a founder or their family is checked the same way as any other insider contract.
## Splitting one borrower into many
**The trick.** The limit is $3 million per co-op. So a promoter, franchisor or landlord sets up 30 "separate" co-ops, each run by a relative or employee, and collects $90 million: a quarter of the fund, controlled by one person.
**The rules:**
- **"Related" means economically tied, not just sharing names.** Co-ops count as related, and share one $3 million limit, if they share a founder or manager, a franchisor, a landlord, a major supplier, an outside investor, or a paid promoter. Family members count as the same person, following the same kind of rules the tax code already uses for family ownership.
- **Caps on middlemen.** No single promoter, consultant or referral source can bring in loans totalling more than 3% of the fund.
- **Money arrives in stages**, as the business hits milestones, so a sham can't take the full amount and disappear.
## Founders paying themselves first
**The trick.** A founder puts in $300,000 as a "loan" and books two years of "unpaid work" at an inflated salary. When the bank's loan arrives, the co-op pays the founder back first, with generous interest. The founder's money is out and safe, and public money has replaced it.
**The rules** (details in [The Founder Problem](founder-problem.md#the-tools)):
- **Founders' loans and back pay rank behind the bank.** They can't be repaid from the bank's loan money, or while any payment to the bank is late.
- **Back pay is valued at no more than the co-op's own pay for that job** once it has revenue. Founder loans earn at most the Treasury rate plus three points.
- **Salaries can't be used to skip the profit split.** Founders can set their own pay in the founding bylaws for at most five years. After that, the members set all salaries. Pay for every role is disclosed to the bank each year, and the gap between the highest and lowest pay is published. Pay bands are open to every worker, nobody votes alone on their own pay, and high pay needs the agreement of the lower-paid half of the members as well as a majority of all of them ([Who Sets the Pay](pay.md)).
- **Payouts from members' accounts pass a solvency test**, so insiders who see trouble coming can't cash out first (see [Member Capital Accounts](capital-accounts.md#protecting-the-co-op-and-the-people-who-stay)).
## Relabeling an old business as "new"
**The trick.** A failing conventional business re-registers as a co-op to get a loan. That brings back the FONDES rescue problem through the side door. It would also flatter the survival numbers, because the business has already made it through its riskiest early years.
**The rule.** "New" describes the business, not the paperwork. If a co-op takes over an existing firm's assets, customers, location or most of its staff, it isn't new. It's either refused as a rescue or scored with the existing co-ops.
## Keeping zombies alive
**The trick.** If the test fails, the bank shuts down. So the bank's own staff have the strongest reason of anyone to make the numbers look good. They can stretch out loans, let unpaid interest pile up, and keep dead firms technically open. A healthy co-op can also absorb a dying one, so the closure never shows up.
**The rules:**
- **"Open" means actually operating:** at least three members and sales above a minimum level. An empty shell counts as closed.
- **Loans are valued honestly.** At review, outstanding loans are valued at what a buyer would pay. A loan whose terms were eased to avoid default is marked down. Unpaid interest added to the balance doesn't count as income.
- **Mergers are treated the same on both sides.** A co-op absorbed by another co-op, and a conventional firm bought by another firm, are both counted as neither success nor failure. A second analysis counts them both ways.
- **A merger that hides a failure counts as a failure.** If a co-op was behind on its loan payments, or below the minimum sales level, at any point in the year before it merged, the merger counts as a closure. Nobody working for the bank, including [the advisers](pilot-spec.md#the-support-lottery), may arrange or suggest a merger.
## Sabotage
**The trick.** An opponent recruits 25 or 30 plausible applicants, has them draw down their loans, strip the money and default. That pushes losses past the emergency stop and freezes the pilot before it reaches a verdict.
**The rules:**
- **Fraud doesn't end the experiment.** Under the [emergency stop](pilot-spec.md#the-decision-rule), lending restarts once the reviewers confirm the specific gaps are closed and the board signs off. Losses that are later recovered are credited back.
- **The staged payouts and middleman caps above** limit how much any ring can take before it's noticed.
## Rigging the review
**The trick.** The headline thresholds are fixed, but a lot of smaller choices aren't: which firms count as matches, how closures are detected in the data, which years are included. Lobbyists on both sides would fight over each one, and whoever picks the reviewers picks the answer.
**The rules:**
- **The analysis code is filed in advance**, not just the plan, along with a digital fingerprint that shows it hasn't changed.
- **The academic reviewers are nominated by the National Academies of Sciences**, and nobody with ties to the bank or to co-op advocacy groups can serve.
- **Any change from the filed plan must be signed, explained and published**, and the result under the original plan is always reported next to it.
## Rigging the price
**The trick.** A friendly bank chief could quietly undercharge co-ops, which is a hidden subsidy. A hostile one could overcharge them into failure.
**The rule.** The [annual rate audit](pilot-spec.md#what-market-rate-means) works in both directions. Undercharging is counted as a loss against the bank's results. Overcharging gets the affected loans repriced.
## Political favoritism
**The trick.** This is what FONDES actually did: politicians steer loans to allies.
**The rules:**
- **Every loan is public:** the borrower, the amount, the rate and the terms.
- **Every contact from an elected official or their staff about a specific loan is logged and published.**
- **The board answers to elected officials. Individual loans don't.** The President can appoint and remove the bank's board at will. But by law nobody outside the bank can direct a decision on a particular loan except through the board, the board can overrule its staff only in writing and in public ([Who has the last word](durability.md#who-has-the-last-word)), loan officers are career bankers the board hires and can fire only for cause, not political appointees, and a new board can't change the filed lending standards without the results being reported both ways (see [Making It Last](durability.md#what-a-new-board-cant-change)).
@@ -0,0 +1,210 @@
---
title: The Pilot, in Numbers
section: Growing the Sector
order: 65
summary: The concrete pilot: how much money, how many co-ops, what gets measured, against whom, and the exact results that grow the bank or shut it down.
---
# The Pilot, in Numbers
[The Test](the-test.md) says the bank runs as an experiment. You can't fault an experiment that has no numbers, so this page supplies them. Every number here is a first draft and open to argument. What matters is that they're written down *before* the first loan, so nobody (me included) can pick them after seeing the results.
The rules that stop people from gaming the pilot (sham co-ops, hidden owners, rigged comparisons) have their own page: [Guarding the Pilot](pilot-safeguards.md).
## The short version
| | |
| --- | --- |
| **Money** | $350 million, once, plus a separate $100 million for [worker buyouts](buyouts.md). |
| **Lending window** | Years 1–5 (up to year 8 if good applicants are scarce). |
| **Target** | About 400 new worker co-ops. |
| **Loan size** | Up to $3 million per co-op, including all later loans. |
| **Price** | Market rate: what a similar conventional business would pay. No personal guarantees from members. |
| **Compared against** | Similar new conventional firms that got similar-sized loans, plus a lottery among borderline applicants. |
| **Final review** | Year 12, by an independent reviewer. |
| **Passes if** | Co-ops stay open about as well as the comparison firms *while paying their members fairly*, and the bank earns at least what the money would have earned in Treasury bonds. |
| **Shuts down if** | Either of those fails. |
The rest of this page explains each line.
## Two warnings from real life
Two public efforts to fund co-ops went badly. The pilot is built to avoid repeating them.
**Uruguay's FONDES.** In 2011 Uruguay set up a public development fund to lend to worker-run firms. By 2016 it had funded 22 firms, and almost 90% of its loans (US$63 million of US$69 million) had been written down as uncollectable. Eight firms got 93% of the money. Three with close ties to the governing party got nearly half. The biggest single loan, US$15 million, went to Alas Uruguay, an airline formed by the workers of the failed state airline. It stopped flying ten months after launch. The original bill capped any one firm at 3% of the fund, but lawmakers raised the cap to 15% before passing it. ([El Observador, 2017](https://www.elobservador.com.uy/nota/-en-que-estan-los-principales-emprendimientos-financiados-por-el-fondes--20176201600); [El Observador, 2015](https://www.elobservador.com.uy/nota/mitad-de-dinero-del-fondes-fue-para-empresas-companeras--201572500))
What went wrong: most of the money went to *rescuing firms that had already failed* under other owners, a few borrowers got most of it, and politics chose them. Uruguay reformed the fund in 2015. It added a 10% cap per business group and a limit of two loans per firm in five years ([la diaria, 2016](https://ladiaria.com.uy/articulo/2016/6/arrancando-de-cero/)).
**Venezuela's co-op boom.** After 2001, Venezuela gave co-ops cheap state loans, first pick of government contracts, and tax breaks. Registered co-ops went from under 800 to over 100,000 in a few years. A 2006 government census found only about 50,000 actually operating. Many had been set up to collect the benefits, not to run a business ([YES! Magazine, 2007](https://www.yesmagazine.org/issue/latin-america/2007/05/12/venezuelas-co-op-boom); [Grassroots Economic Organizing](https://geo.coop/blog/some-counter-intuitive-effects-government-support-co-ops-venezuela)).
What went wrong: cheap money attracts people who want the money, not the co-op. That's the main reason [the bank](the-bank.md) charges market rates.
## Why a new bank, not existing programs
Fair question. Congress already made co-ops eligible for the Small Business Administration's (SBA's) main loan program in 2018, through the Main Street Employee Ownership Act. It has barely been used. The SBA kept requiring personal guarantees on those loans, meaning individual members pledge their own homes and savings if the business fails. That doesn't work for a firm owned by dozens of workers ([House Committee report, 2018](https://www.congress.gov/committee-report/115th-congress/house-report/645/1); [NCEO, 2022](https://www.nceo.org/employee-ownership-blog/house-committee-urges-sba-fully-implement-main-street-eo-act)).
Private co-op lenders exist and do good work, but they're small. Shared Capital Cooperative, lending to co-ops since 1978, had about $20 million in assets in 2021 ([offering statement](https://www.sec.gov/Archives/edgar/data/1722839/000121390021050617/ea148092-1a_sharedcapital.htm)). The pilot's question is what happens when financing on normal commercial terms is available at real scale. The existing channels can't answer that.
## How much money, and why
**$350 million, one time.** In the pilot, the bank lends only its own capital. It doesn't borrow on top of it. That makes the worst case easy to state: taxpayers can lose at most $350 million, minus whatever is repaid. A separate $100 million for owners who choose to sell to their workers has its own rules and its own test ([When an Owner Wants to Sell to the Workers](buyouts.md)), which makes the total worst case $450 million.
It breaks down like this:
- **About $290 million for loans.** Around 400 co-ops with a typical first loan of about $500,000 is roughly $200 million. The rest is for follow-on loans to co-ops that grow. For comparison, Shared Capital's average loan is about $230,000. The pilot's loans are larger because it's meant to fund real businesses with real payrolls, not only very small ones.
- **$30 million, capped, for business help:** $20 million after the first loan (bookkeeping, governance training, legal setup, and more intensive help for half the co-ops, chosen by lottery; see [the support lottery](#the-support-lottery)), and $10 million before it, taken from the loan budget, for a lottery testing whether serious help makes more groups succeed in starting a co-op that lasts ([Help Before the First Loan](before-launch.md)). Venezuela's census blamed many of its failures on the lack of exactly this. This money is a public cost, stated openly and capped, and it's counted in the pilot's total cost to taxpayers. It isn't allowed to hide inside loan pricing.
- **The remainder** is a cash cushion and running costs. Running costs include audits of small co-ops: about 20 a year drawn by lot, plus those triggered by warning signs, perhaps 30 to 50 in all. At $10,000 to $30,000 each, in line with the SEC's estimates for small firms, that's roughly $300,000 to $1.5 million a year. Like any lender's checking, it's a cost of lending, paid from the bank's income and built into its market rate. Audits of buyout co-ops are charged to the buyout track ([What the Rules Cost a Small Co-op](compliance.md#audits-by-lot-and-when-something-looks-wrong)).
## Why about 400 co-ops
This is the number that lets the test tell a real answer apart from luck.
New businesses close a lot. Of all US business locations opened in 2013, only about 35% were still open ten years later ([Bureau of Labor Statistics](https://www.bls.gov/bdm/us_age_naics_00_table7.txt)). With closures that common, a small pilot is noise: 30 co-ops could look great or terrible by chance.
To be confident that co-ops do "at least about as well" as conventional firms, you need to see several hundred closures across both groups. Standard survival statistics say that about 400 co-ops, each compared with five similar conventional firms, give roughly a 90% chance of a clear verdict if co-ops really are exactly as durable. If they're *more* durable, as the best existing study suggests (below), a clear verdict is easier to reach.
That also means a roughly 10% chance of shutting down a pilot that was actually working. That's the cost of putting the burden of proof on the co-ops, and it's stated openly.
**The honest problem with 400.** The whole US worker co-op sector was about 751 firms in 2023, roughly triple what it was a decade earlier ([NCEO summary of the Democracy at Work Institute census](https://www.nceo.org/employee-ownership-blog/democracy-at-work-institute-releases-its-2023-worker-cooperative-state-of-the-sector-report)). That's net growth of about 50 co-ops a year, after counting the ones that closed. Reaching 400 in five years means about 80 a year, roughly 1.6 times that pace, on top of whatever else the sector adds.
That creates exactly the pressure that sank Venezuela: an institution with a target lowers its standards to hit it. So:
- **No lending targets.** Nobody is paid, promoted or judged on loan volume. Part of the loan money is set aside for co-ops in lower-paid work, but that's money made available, not a target ([Who the Bank Lends To](who-gets-loans.md)).
- **If good applications fall short, the lending window stretches** (up to year 8) instead of standards dropping. The final review moves back by the same amount. That rule is fixed now.
- **A shortfall is itself a result.** If good co-op applicants don't show up even when financing is available, that's evidence for the "co-ops are rare because they're hard, not because they're unfunded" explanation, and it gets reported that way.
## Who can borrow
- **New worker co-ops only, for the main test.** "New" means the *business* is new, not just the legal paperwork. A conventional firm that reincorporates as a co-op isn't new (see [Guarding the Pilot](pilot-safeguards.md)).
- **Existing co-ops may borrow too, but they're scored separately.** Firms that have already survived for years would make the results look better than they are.
- **No rescues of failed conventional firms.** That's where FONDES lost its money.
- **Healthy firms whose owners choose to sell to their workers** can borrow on a separate track, with its own money and its own test, so they can't flatter the main result ([When an Owner Wants to Sell to the Workers](buyouts.md)).
- **At least three worker-members at the first loan**, meeting the [co-op definition](worker-ownership.md) and accepting [the lock](the-lock.md).
- **Members are on the payroll as employees.** That gives them minimum wage and workers' compensation like any other worker, and unemployment insurance in most states ([Owners Are Still Workers](labor-law.md)). It also means the co-op shows up in the government business records the test relies on.
- **Any legal industry.** Loan officers judge business plans, not industries. But no single industry may take more than 25% of the money lent, and no single county more than 10%, so one bad sector or one local market can't sink the whole test.
## Loan terms
- **$3 million per co-op, lifetime.** That's under 1% of the fund. FONDES let one airline take over a fifth of its money. That can't happen here. Related co-ops share one limit (see [Guarding the Pilot](pilot-safeguards.md)).
- **Paid out in stages** as the business hits agreed milestones, not in one lump.
- **No personal guarantees from members.** The risk that the business fails is exactly the risk the bank exists to price and carry. Loans can be secured by the business's own assets, like any business loan.
- **Underwriting can rely on cash flow**, not only on collateral. Collateral-heavy lending mostly measures how much wealth the founders already had, which shuts out the workers co-ops tend to serve.
## What "market rate" means
The bank prices each loan the way a normal lender would price a conventional business with the same numbers: same revenue, same industry, same track record, same collateral. Because there are no personal guarantees, the rate is higher than it would be with them. That's the honest price of the risk.
Two checks keep the price honest:
- **An annual audit**, in both directions. Each year an outside auditor compares a sample of the bank's loans with rates on comparable conventional small-business loans. It also checks the things that don't show up in the rate: who gets approved, how much collateral is required, and how often troubled loans get softer terms. If loans are systematically too cheap, the shortfall is counted as a loss against the bank's results. If they're too expensive, the affected loans are repriced.
- **A private market check.** Every loan is offered to private and community lenders, who can buy up to 20% of it on the same terms. How often they say yes is published. If nobody ever wants a piece, that suggests the price is too low for the risk.
## What gets measured
All of this is written down and filed in a public study registry before the first loan, the way serious medical trials and a few careful social-policy studies already do. One example is the [Oregon Health Insurance Experiment](https://www.nber.org/programs-projects/projects-and-centers/oregon-health-insurance-experiment/oregon-health-insurance-experiment-background). The actual analysis code is filed too, so the method can't quietly change later.
**The two measures that decide the outcome:**
1. **Fair survival.** Does the co-op stay open and keep treating its members fairly? A co-op counts as *failed* if any of these happens:
- it closes, or falls below three members or a minimum level of sales;
- for two years running, its members' median pay per hour (wages plus profit share) falls below 85% of the matched conventional firms' median;
- it stops being a real co-op: from its third year, fewer than three-quarters of its work hours are done by members, or its workers are kept on probation past the limit, or it pushes people out before they'd become members ([When People Don't Stay Long](high-turnover.md)).
The pay rule matters. Co-ops can survive a bad stretch by cutting their own pay instead of cutting jobs, and the best existing study couldn't rule that out. A co-op that survives by paying its people far less than the business down the street hasn't passed.
2. **The bank's money.** At the end, after every loss, write-off and running cost, and every dollar spent on business help, has the loan book earned at least what the same money would have earned in Treasury bonds over the same years? Anything less means taxpayers lost money compared with just holding bonds. Loans still outstanding are valued at what a buyer would pay for them. Loans that were restructured to avoid default are marked down, so going easy on struggling borrowers can't hide losses.
A useful yardstick: among the SBA's mature loan groups from 1992 to 2004, loans making up about 6% to 10% of the money lent eventually defaulted, and about 37% of the defaulted amount was later recovered ([GAO-20-618](https://www.gao.gov/products/gao-20-618)). A lender charging market rates with losses in that range covers them from interest.
**Measures that are published but don't decide:** jobs created (with growth compared with matched firms, spin-offs, and expansions members voted down; see [When Growing Means Sharing](growth.md#5-measuring-it)), wages at the bottom and top and the gap between them, the share of pay above the going rate, how often higher-paid members leave, pay gaps by gender and race within the same job ([Who Sets the Pay](pay.md)), revenue per worker (a rough measure of productivity) and value added per hour worked, followed as co-ops grow ([Does Sharing the Profit Mean Less Effort?](free-riding.md#measuring-it)), how long members stay, injury rates, and the bank's default and recovery rates. Also:
- **Who got loans.** Approval rates and loan terms by the founders' race, gender and personal wealth. If collateral-based lending quietly shuts out the people co-ops mostly serve, it should show. Results are also reported for co-ops in lower-paid work and for first-time owners without much wealth ([Who the Bank Lends To](who-gets-loans.md)).
- **Who gets the profits inside each co-op.** Profit shares, founding shares and membership approvals by race and gender, so that an in-group of early members can't quietly take most of the pool.
- **Who founded them.** Founders' previous earnings and work histories, using linked government records where they're available, compared with the founders of the matched firms. If the strongest founders stay away ([The Founder Problem](founder-problem.md)), this is where it shows. How it's measured, and how the verdict has to say how much founders explain, is in [Co-ops or Their Founders?](who-founds.md)
- **Investment.** Spending on equipment, buildings and development per worker, how much of each year's profit is kept in the business, and the return those investments earn, compared with the matched firms. If co-ops starve themselves of capital, as economists have long predicted ([Investing for the Long Run](the-long-run.md)), this is where it shows.
- **Local effects.** Business openings and closures in the counties where co-ops were funded, so any damage to existing local businesses is visible.
- **Results by industry**, including care work like home health and cleaning, where most US co-op members work today.
Why only two deciding measures: the more measures you allow to count, the easier it is to find *one* that looks good. Two, fixed in advance, can't be gamed that way.
## Who co-ops are compared against
This is the hardest part of any test like this. Co-ops that got a loan aren't like the average new business. They had a plan good enough to get a loan. Comparing them with *all* new businesses would flatter them.
**Main comparison: new conventional firms that got a similar-sized loan through the SBA's main program.** They're matched on industry, region, start year, starting size, legal form, and the number of founders. Businesses with more founders tend to survive longer, and co-ops always have at least three. This group is imperfect in both directions. SBA borrowers had to show they couldn't get credit elsewhere, which makes them a weaker comparison. But their loans carry a government guarantee that makes credit cheaper for them, which makes them a stronger one. The reviewer reports both biases openly. Researchers have already matched SBA borrowers with similar firms in government business records ([Brown and Earle, *Journal of Finance*, 2017](https://papers.ssrn.com/abstract=2641184)), so the data work is feasible.
**Second check: a lottery at the margin.** Among applicants whose scores land just around the approval line, the bank decides by lottery who gets a loan. Then it follows both groups. This is the cleanest test of the core claim: *does access to financing make co-ops that would otherwise not exist, or not survive?* The lottery group will be small, so it supports the main result rather than replacing it.
**Third check:** co-ops against *all* similar new firms.
**Sales count the same way on both sides.** A conventional firm that gets bought hasn't necessarily failed, and a co-op can't be bought at all because of [the lock](the-lock.md). So acquisitions and mergers, on either side, are counted as neither success nor failure in the main analysis, with a second analysis counting them both ways.
**What existing evidence predicts.** The strongest study so far followed every firm in Uruguay from 1997 to 2009: about 29,000 firms, 223 of them worker-run. After leaving out very small businesses and adjusting for tax differences, worker-run firms were about 29% *less* likely to close in any given period than conventional ones ([Burdín, *ILR Review*, 2014](https://journals.sagepub.com/doi/10.1177/001979391406700108)). The advantage was clearest in services. In manufacturing and transport there was no clear difference. That's encouraging, but it's one country, and it's why pay is built into the survival measure above.
## The support lottery
Mondragon's bank gave its co-ops far more than loans. It gave them a salaried manager while they planned, specialist departments for marketing and production, a staff member who sat in on board meetings for the first year, and monthly check-ins on the numbers ([Has This Been Tried?](precedents.md#mondragons-bank-caja-laboral)). Nobody knows whether the loans or the help did the work. The pilot can test part of that cheaply, so it does.
**How it works:**
- **Every co-op gets standard help:** setting up the books, legal setup, and training in how to run a co-op meeting and a co-op budget. That's about $15,000 per co-op, around $6 million in all.
- **Half also get intensive help for two years, chosen by lottery.** That means an experienced adviser who attends board meetings without a vote, a monthly review of the numbers, and access to specialists in marketing, operations and pricing. It costs about $70,000 more per co-op, around $14 million in all.
- **The draw is made by the reviewers, not the bank**, after each co-op's first loan payment, using a random method filed in advance. It's balanced by industry and by the founders' wealth. Every co-op is analyzed in the group it was drawn into, whether or not it uses the help, so a co-op can't improve the numbers by opting in or out.
- **Each co-op's help budget is set aside when it's drawn.** If money runs short, the intensive group gets smaller, but no co-op's help gets thinner.
- **Co-ops agree to the lottery when they apply**, and the design is approved by an independent research ethics board. Co-ops in the standard group can ask for intensive help after year four, once the main comparison has been measured.
**Advisers are walled off from the bank's interests.**
- **They're hired through outside nonprofit providers on fixed fees.** They aren't bank staff, and nothing they're paid depends on whether their co-ops survive.
- **They advise; they don't run anything.** All advice is written and non-binding. They can't vote, sign anything, or take part in hiring or firing, and the members can replace their adviser at any time.
- **They can't arrange rescues.** Advisers can't arrange or suggest mergers, loan changes or sales of the business. A co-op's merger counts as a closure if it was behind on payments or below the minimum sales level in the year before (see [Keeping zombies alive](pilot-safeguards.md#keeping-zombies-alive)). Merger and restructuring counts are published for each group.
- **They're treated as insiders.** An adviser and their firm can't sell paid services to a co-op they advised for three years afterward. Someone who referred a co-op for a loan can't be its adviser.
- **Their one reporting duty is fraud.** They report suspected fraud to the reviewers, and nothing about business performance goes to the lending side.
**The help is a subsidy, and it's counted as one.** Intensive help is worth something like a sixth of a typical first loan. So:
- **Every dollar of business help counts against the bank** in the [money test](#the-decision-rule), just like a bad loan.
- **The survival result for the standard-help group alone is published next to the main result.** If co-ops only hold their own with intensive help, readers will see that.
- **Comparison firms' use of free government business counseling**, such as the SBA's Small Business Development Centers, is recorded where the data allows.
**What it can and can't show.** Assume about 200 co-ops in each group and closures in line with new businesses generally. Then the pilot can reliably detect only a large effect, something like 30 to 40% fewer closures in the intensive group. So its main outcome is survival and fair pay at year four, when the help's effect should be clearest, with year twelve reported as well. The result is reported as a range, with a cost per closure avoided, and it counts as exploratory. **A result that isn't statistically significant doesn't prove the help is useless.** It means only that a large effect has been ruled out.
**What it doesn't test.** Mondragon's help started *before* a co-op existed: studying the idea, paying the manager, lending to cover early losses. This lottery starts after the loan, so a second lottery, among groups that want to start a co-op, tests help before launch ([Help Before the First Loan](before-launch.md)).
**If intensive help works,** that's an argument for building it into [the federation](federation.md), paid for by the co-ops out of their shared fund the way Italy's Coopfond is, not for a permanent federal advisory office.
## The timeline
- **Year 0.** Charter the bank. File the full analysis plan, code, benchmarks and thresholds. Name the reviewers.
- **Years 1–5.** Lending window, stretching to year 8 if applications fall short.
- **Year 6.** Public interim report. It can't trigger growth. It can only trigger the emergency stop.
- **Year 12.** Final review. Every co-op funded has been open for at least seven years. That covers most of the danger period for new businesses and, almost certainly, at least one recession.
**The reviewers:** the Government Accountability Office (GAO), Congress's own auditor, which already reviews SBA lending, working with academic economists nominated by the National Academies of Sciences. They must have no ties to the bank or to co-op advocacy groups. The bank's staff don't grade their own work. The reviewers can depart from the filed plan only in a signed, published explanation, and the filed plan's result is always reported alongside.
## The decision rule
At year 12, both measures must pass:
1. **Fair survival.** We can be 95% confident that co-ops' chance of failing in a given year, as defined above, is no more than 25% higher than the comparison group's. If co-ops are exactly as durable or more, this should pass. If they're clearly worse, it fails.
Why allow up to 25% rather than demanding exact equality? No test can prove two things are *exactly* equal. You can only rule out differences bigger than some margin, and a tighter margin takes more co-ops. Ruling out a 10% gap would take somewhere around 1,500 to 2,000 co-ops, more than twice the entire US co-op sector. The 25% margin is also a value judgment, stated openly: a society might reasonably accept firms that are a little less durable in exchange for workplaces people govern themselves. Readers who think that price is too high should say so. Burdín's result suggests the margin won't matter, because co-ops may well come out ahead.
2. **The bank's money** earned at least the Treasury rate, counting every loss, every running cost, and every dollar of business help.
**If both pass:** the charter triggers a fast-track, up-or-down vote in Congress on a second capitalization, with no amendments and no committee bottleneck. It can't be automatic, because no Congress can bind a later one's spending. A fast-track vote is the most that can honestly be promised. If the bank is later allowed to borrow, its debts carry **no federal guarantee**, written into the charter. Fannie Mae and Freddie Mac needed a taxpayer rescue in 2008 partly because markets had always assumed the government stood behind them. This bank shouldn't be able to create that assumption.
**If either fails, the shutdown happens by itself, with no further vote needed.** The board formally adopts the reviewers' result, and after a fail it has to start the loan sale by a deadline the law sets ([Who has the last word](durability.md#who-has-the-last-word)). No new loans. The remaining loans are offered at fair value to co-op and community lenders, who keep the same repayment terms. Whatever they don't buy is collected by the Treasury on the original terms until paid off. None of it goes to distressed-debt buyers. The money goes back to the Treasury. The co-ops stay co-ops, because [the lock](the-lock.md) is in their own charters, but they're on their own.
**There is no "inconclusive, keep going" option.** If the evidence doesn't clearly show co-ops holding their own, that counts as a fail. The burden of proof is on the co-ops, which is where a skeptic would put it.
**The emergency stop.** If net losses ever pass 20% of the capital ($70 million), new lending freezes (the [buyout track](buyouts.md) has its own stop, at $20 million), and the reviewers report within six months on the cause:
- **A recession:** lending restarts only if the SBA's own loans from the same years lost money at a similar rate. That's a fixed comparison, not a judgment call.
- **Fraud:** lending restarts once the reviewers certify the specific gaps are closed and recovery has been pursued, and the board signs off in writing. Otherwise one organized fraud ring could end the experiment on purpose (see [Guarding the Pilot](pilot-safeguards.md)).
- **Bad lending decisions:** no restart. That's a result.
## What this pilot can't tell you
- **Whether the labor market changes.** 400 co-ops won't move anyone's wages. How big the sector would need to get, how long that could take, and how the effect could be tested locally after a pass are in [After a Pass: How Big, How Fast](scale.md).
- **Whether the best founders come.** The test measures the co-ops that *did* apply. If strong founders stay away ([The Founder Problem](founder-problem.md)), a pass means "the co-ops that exist can compete," not "co-ops would win with everyone's best people." The pilot can measure how much founders' backgrounds explain the result, but it can't see the founders who never applied ([Co-ops or Their Founders?](who-founds.md)).
- **Whether co-ops do better with a full support network.** The pilot funds individual co-ops. It doesn't build [the federation](federation.md) that Mondragon relied on, so co-ops that join a federation are reported separately. The [support lottery](#the-support-lottery) tests one piece of that network, hands-on help, but not the rest: shared funds, moving workers between co-ops, joint buying and selling.
- **Whether it survives politics.** Congress can refuse the second capitalization, or repeal the whole thing. A hostile board could also try to rig the result, which is why the verdict can be "not tested" rather than "failed" if the bank turned away applicants who met its own standards. See [Making It Last](durability.md).
@@ -0,0 +1,88 @@
---
title: Has This Been Tried?
section: Growing the Sector
order: 55
summary: Three real institutions that financed co-ops (Mondragon's bank, America's co-op bank, and Italy's co-op funds), what they got right, how they went wrong, and what the pilot takes from each.
---
# Has This Been Tried?
A bank that lends to co-ops isn't a new idea. At least three serious versions exist, and each teaches something different. One built a whole economy of worker co-ops, and then quietly became an ordinary bank. One was created by Congress, and ended up lending to expensive New York apartment buildings. One is funded by co-ops themselves and still holds most of what it has collected over thirty years.
This page goes through what each one actually did, with the failures left in, and what [the bank](the-bank.md) and [the pilot](pilot-spec.md) take from them.
## Mondragon's bank: Caja Laboral
**What it was.** In 1959 the young Mondragon co-ops in Spain's Basque Country founded their own savings bank, the Caja Laboral Popular ("the Working People's Bank"). Local people deposited their savings there, and every member co-op was required to bank there too. Until the 1980s it lent almost only to co-ops. Apart from some government job-creation money, it was their only source of capital from outside their own members, and its loans made up about half of the co-ops' capital ([Morris, *The Mondragón System*, 1992](https://www.ilsr.org/wp-content/uploads/2013/03/mondragon-3.pdf)).
**What made it unusual wasn't the money. It was the help.** In 1969 the bank set up a separate Entrepreneurial Division, which worked more like a business incubator than a loan desk (Morris, 1992):
- When a group of workers came with an idea, the bank picked one of them to be manager, **paid them a salary**, and put them on a team that studied whether the business could work. That often meant visiting factories elsewhere in Europe to learn how the best ones did it.
- The bank had departments for marketing, exports, production planning, factory design, legal setup and accounting, and the new co-op could use all of them.
- The bank put up 60% of the start-up money. The government's job-creation program put up 20%, and the founders themselves 20%. The bank also covered the new co-op's operating losses for its first two years, as a loan to be repaid later.
- One bank staff member was assigned to each new co-op as its "godfather" and sat in on its board meetings for the first year.
- Every co-op sent the bank monthly financial reports and got a full audit every four years.
**The famous number, and why it needs care.** Of the 103 co-ops created between 1956 and 1986, only three were shut down ([Whyte and Whyte, *Making Mondragon*, 1988, as summarized by Participedia](https://participedia.net/case/82)). That counts co-ops the bank rescued, restructured or merged as survivors, and five things sit behind it:
- **The money was cheap.** One new co-op, a maker of seed drills, borrowed at 8% when the going market rate was 15%. The bank could do this partly because every co-op had to do its banking there, which kept its cost of money low, and partly because its managers were paid co-op wages (Morris, 1992).
- **The help was subsidized.** The economist David Ellerman estimated in the early 1980s that what new co-ops repaid for their start-up support covered less than half of what it actually cost the bank (Morris, 1992).
- **Survival often meant rescue.** In Spain's recession of the late 1970s and early 1980s, the bank's business division switched from starting co-ops to saving them. In 1983 alone, 34 of about 100 co-ops needed its intervention department. The bank replaced managers, changed product lines, merged co-ops, and moved workers between them, and it wrote off about $160 million in loans (Morris, 1992). Many of the co-ops that "survived" did so because the bank restructured them. That's a real achievement, but it isn't the same as surviving on their own.
- **The market was protected.** Mondragon's biggest industry was household appliances. In 1980 Spain protected them from imports with a 35% tariff, and that protection only fell away (to 5% in 1991) as Spain joined the European market (Morris, 1992).
- **Then the bank moved on.** Once a change in Basque co-op law allowed it, in the early 1980s, the bank started offering mortgages and car loans to the public, and its deposits grew far beyond what the co-ops needed. By the early 1990s only about 15% of its loans went to co-ops. In 1991 the Entrepreneurial Division was spun off into a separate consulting co-op, LKS (Morris, 1992).
- **And the flagship later failed.** Fagor, the appliance maker at the heart of the group, expanded across Europe and China largely on borrowed money, lost its main market in Spain's housing crash, and went bankrupt in 2013 with about €800 million (about $1.1 billion) of debt ([The Local, 2013](https://www.thelocal.es/20131114/major-manufacturer-fagor-files-for-bankruptcy)). Full group support didn't save it.
**What the pilot takes from it:**
1. **Maybe the help mattered more than the money.** Mondragon combined cheap loans, a protected home market and intensive hands-on support, and nobody can tell which one did the work. The pilot tests one piece of that directly (see [the support lottery](pilot-spec.md#the-support-lottery)).
2. **Count rescues honestly.** A bank that can quietly restructure its borrowers can make almost any survival rate look good. That's why the pilot counts a co-op that's been restructured to avoid default as a loss, not a survivor (see [Keeping zombies alive](pilot-safeguards.md#keeping-zombies-alive)).
3. **A co-op bank drifts toward ordinary lending once co-op demand runs out.** The pilot's bank can lend only to worker co-ops, and the bank can't change its own charter (see [The Bank](the-bank.md)). That doesn't stop a legislature from changing it, which is how Caja Laboral's drift began.
4. **The pilot doesn't copy the cheap money.** It lends at market rates, for the reasons on [the bank's page](the-bank.md). The help it gives *is* a subsidy, so it's counted against the bank's results and tested separately (see [the support lottery](pilot-spec.md#the-support-lottery)).
## America's co-op bank: the National Consumer Cooperative Bank
**What it was.** In 1978 Congress created the National Consumer Cooperative Bank to lend to co-ops that couldn't get credit from ordinary banks. It came with a separate office to give new co-ops technical help and start-up money. The law authorized up to $300 million of federal capital over five years ([Public Law 95-351](https://www.congress.gov/95/statute/STATUTE-92/STATUTE-92-Pg499.pdf)). According to one of its later board members, the public investment that actually went in was about $100 million, to be repaid over 40 years ([Barnes, board member 1982–86](https://peter-barnes.org/blog/what-ever-happened-to-the-national-cooperative-bank/)).
**Why it never became a worker-co-op lender.** The law was written for co-ops whose members are *consumers*: food co-ops, housing co-ops, co-ops that buy things together for their members. To qualify, a co-op has to serve members "who are ultimate consumers" of what it provides. Co-ops of *producers* can borrow, but loans to them are capped at 10% of the bank's assets ([12 U.S.C. § 3015](https://www.law.cornell.edu/uscode/text/12/3015)). A worker co-op, whose members supply their work rather than buy its products, fits that law awkwardly at best. Worker co-ops were never what it was built for.
**What happened to it.** The Reagan administration tried to shut it down in 1981. Congress resisted, and the compromise was to privatize it. On December 31, 1981 it became a co-op owned by its borrowers, and by 1985 it was fully private and renamed the National Cooperative Bank ([Next City, 2019](https://nextcity.org/urbanist-news/the-weirdest-bank-youve-never-heard-of); [Encyclopedia.com](https://www.encyclopedia.com/law/encyclopedias-almanacs-transcripts-and-maps/national-cooperative-bank)). It survived, and it had about $2.6 billion in assets in 2019. Its technical-help office was later spun off as a separate nonprofit ([12 U.S.C. § 3051](https://www.law.cornell.edu/uscode/text/12/3051)). But in the words of one of its former directors, it became "a big lender to fancy New York City housing coops, large grocery wholesalers" and other "healthy businesses, few of whom need help getting credit" (Barnes).
**What the pilot takes from it:**
1. **The charter decides who gets served.** A bank "for co-ops" will lend to whichever co-ops are safest. The pilot's bank is chartered for worker co-ops only, and only those that accept [the lock](the-lock.md).
2. **A target nobody enforces is not a target.** The 1978 law told the board to use its "best efforts" to keep at least 35% of its loans going to co-ops of low-income people or facilities they use (Public Law 95-351). Missing that target had no consequence, and the bank drifted toward borrowers that could have borrowed anywhere. The pilot has fixed measures, published in advance, and a date when it has to answer for them (see [The Test](the-test.md)).
3. **Politics can change what a bank is.** It survived an attempt to close it by becoming something else. A charter can't protect against the legislature that wrote it. The pilot is built so that a failed test shuts it down cleanly, rather than letting it drift into a different mission (see [The Constitution](constitution.md)).
## Italy's co-op funds: Coopfond and the Marcora Law
Italy has two different kinds of co-op fund, and both are useful.
**Coopfond: money from the co-ops themselves.** Since 1992, Italian law has required every co-op to pay 3% of its annual profit into a co-op development fund, usually the one run by its federation ([Lomuscio and Tortia, 2023](https://pdfs.semanticscholar.org/a7eb/90b586cb2adb63c321f1ca616afefb27eb70.pdf)). Coopfond is the fund of Legacoop, the largest federation. It also receives whatever is left of a co-op's assets when it's wound up, or when it converts into an ordinary company. It invests in co-ops through loans and temporary, time-limited stakes, and recycles the money as it's repaid. From 1993 to 2023 it collected about €596 million. After three decades of losses and grants, about €499 million, or 91% of what it collected, is still in the fund. Around 2,400 co-ops pay in, about €18–20 million a year ([Coopfond, 2022–23 sustainability report](https://www.coopfond.it/wp-content/uploads/bilancio24_compressed.pdf)).
Two cautions. Those are euros as collected, not adjusted for thirty years of inflation, so the fund's real value has shrunk more than the 91% suggests. And it isn't a one-time endowment: new money arrives every year, and Italian co-ops pay the 3% alongside generous tax breaks on their locked reserves. Still, it's the closest real-world model for the [federation's](federation.md) shared fund: the movement financing its own growth without spending its capital away.
**The Marcora Law: public money matched to workers' own.** Under a 1985 law named after the industry minister who proposed it, Giovanni Marcora, the Italian state helps workers buy out firms that are closing. It invests alongside the workers through CFI, a specialist co-op investor whose members include the industry ministry, Italy's business development agency Invitalia, and about 270 co-ops. Before investing, CFI studies whether the business can work and advises the workers, much like Mondragon's Entrepreneurial Division. Workers put in their own money too, often from their severance and unemployment benefits ([Vieta, Euricse working paper, 2015](https://base.socioeco.org/docs/wp-78_15_vieta.pdf)).
The survival record is good. Of the 82 buyouts started between 2003 and 2014, 71 (87%) were still operating at the end of 2014 (Vieta, 2015). That's a young group, though, and buyouts are chosen with care: workers only try one when they're confident the business can work.
These are rescues of existing businesses, which [the pilot](pilot-spec.md#who-can-borrow) keeps out entirely because of Uruguay's experience. It does lend for sales of *healthy* firms whose owners choose to sell to their workers, on a separate track ([When an Owner Wants to Sell to the Workers](buyouts.md)). The difference is in how they're chosen. Marcora buyouts need the workers' own money and a specialist's study of whether the business can work. Uruguay's went mostly to politically connected firms that had already failed.
**The warning in the Marcora story.** The original law let the state put in up to three euros for every euro the workers invested. After Italy's main employers' association intervened, the European Union ruled that this broke its competition rules by giving worker buyouts an unfair advantage, and the program was suspended. A 2001 reform cut the state's share to one euro per worker euro, to be repaid over 7 to 10 years. From 2000 to 2006, while the rules were being rewritten, CFI made no new investments, and only a handful of buyouts happened at all (Vieta, 2015).
**What the pilot takes from it:**
1. **A movement fund can last.** Coopfond shows that a small cut of co-op profits, lent and invested carefully, can keep its value for decades. The federation fund is modeled on it.
2. **Workers' own money in, alongside the public's.** Both Mondragon (20% from founders) and Marcora (one to one) required workers to put money in. The pilot now asks founders for a small stake, at least 5% of the first loan, which they can pay in unpaid work instead of cash, because a cash buy-in shuts out workers with no savings ([Help Before the First Loan](before-launch.md)).
3. **Subsidies get challenged.** Marcora's generous matching was the part that got struck down, and the program lost six years. The US has no European-style rule against state aid, but the political version of the same attack ("unfair competition with taxpayer money") works anywhere. A bank that lends at market rates is much harder to attack that way. That's one more reason the pilot's bank doesn't subsidize its loans, and why the help it does give is costed openly.
4. **Italy locks the reserves, not the firm.** An Italian co-op *can* turn itself into an ordinary company, but its accumulated reserves go to the co-op development fund, not to the members. That's a softer version of [the lock](the-lock.md), and it's weighed in [Open Problems](open-problems.md).
## The pattern
| | Mondragon's bank | US co-op bank | Coopfond | Marcora / CFI |
| --- | --- | --- | --- | --- |
| **Money came from** | Local savers and the co-ops' own deposits | Federal capital | 3% of co-op profits | The state, matched to workers' money |
| **Hands-on help** | Very intensive | A separate office, later spun off | Some | Feasibility study and advice |
| **Who it served** | Worker co-ops | Mostly housing and consumer co-ops | Co-ops of all kinds | Worker buyouts |
| **What went wrong** | Rescues flattered its record; it drifted into ordinary banking; its flagship failed | Drifted to safe borrowers after privatization | Its real value shrank with inflation | Its subsidy was ruled unfair and the program suspended |
Four cases can't prove a rule. But two patterns stand out. The two that actually built worker co-ops, Mondragon and Marcora, both paired money with serious hands-on help before and after launch. And both banks drifted once their mission became optional, one because the law changed and one because nobody enforced its target. The pilot takes both lessons: it tests whether the help matters, and its mission comes with a deadline and a verdict.
@@ -0,0 +1,20 @@
---
title: Prison Labor
section: Beyond Co-ops
order: 120
summary: Only with full pay parity, and only if no budget ever benefits from more people being locked up.
---
# Prison Labor
Incarcerated people work **the same jobs, at the same pay, side by side with free temps.**
Pay parity is not a detail. It is the entire moral load-bearing wall, and it is the first thing a critic will assume was left out, so it goes in the first sentence, always.
Pay parity is what separates this from convict leasing. The historical horror ran on a *cost advantage*: after the Civil War, Southern prisons became labor suppliers and arrests rose to meet demand. The Thirteenth Amendment abolished slavery *"except as a punishment for crime,"* and that clause is why it worked, and it is still in the Constitution. Remove the cost advantage and the state gains nothing by locking more people up.
**The test, applied forever:** *if automation arrived tomorrow and every prison job vanished, would any budget line get harder to balance?* If yes, the trap is live and the design has failed.
Required guardrails: pay parity, genuinely optional participation, no quotas, no budget line that improves with a larger prison population.
**The goal is reform, not punishment.** People should leave prison with savings, a work history, and a reference. Employment after release is one of the strongest predictors of not going back.
@@ -0,0 +1,180 @@
---
title: Protecting Members' Savings
section: The Co-op
order: 26
summary: If a co-op fails, its workers shouldn't lose their last paychecks and their savings along with their jobs. How an insurance pool paid for by the co-ops protects them, and what it can't promise.
---
# Protecting Members' Savings
Every member of a co-op builds up savings in a [capital account](capital-accounts.md). That money is real, but it sits inside the same business that pays the member's wages. If the co-op fails, its lenders get paid first, and members' accounts are paid only from whatever is left. A worker can lose their job and their savings on the same day. Their last few paychecks may go unpaid too.
The model already limits how much can pile up (accounts are capped at a year's pay) and stops insiders cashing out first (every payout has to pass a solvency test). This page adds the missing piece: insurance.
## What goes wrong in real life
- **Fagor, 2013.** When Mondragon's appliance maker went bankrupt, more than 900 former workers of Fagor and a related co-op sued Mondragon for €47.8 million of capital contributions they had lost. The courts turned them down, ruling in 2018 that the claim had been filed too late ([Europa Press, via Infobae, 2023](https://www.infobae.com/espana/agencias/2023/11/12/diez-anos-de-la-caida-de-fagor-electrodomesticos-cronologia-de-una-quiebra/)). Some savers had also bought Fagor's subordinated bonds believing they were ordinary deposits ([elDiario.es, 2023](https://www.eldiario.es/euskadi/diez-anos-quiebra-fagor-electrodomesticos-tambaleo-esencia-cooperativismo-vasco_1_10612926.html)). Mondragon did protect most of the *jobs*. Of 1,736 members in the Basque plants, 577 became members of other co-ops, 320 were moved temporarily, and many more retired early or were compensated, paid for partly by doubling contributions to Mondragon's own social insurance fund (elDiario.es). It had no equivalent protection for their savings.
- **Italy's consumer co-ops, 2014–15.** Italian consumer co-ops let their members keep savings with them, as a "social loan" to the co-op. When two co-ops in Italy's northeast failed, thousands of members' savings were frozen. At one of them, Coop Operaie di Trieste, 17,662 members held social-loan savings, and a rush of withdrawals set off the collapse ([Wikipedia, in Italian](https://it.wikipedia.org/wiki/Cooperative_Operaie_di_Trieste_Istria_e_Friuli)). At the other, CoopCa, about 3,000 members had around €27 million at stake. Under the rescue plan they could expect three-quarters back at best, and not before 2018. Neighboring co-ops voluntarily paid them up to half of their money, €13 million in all ([Il Fatto Quotidiano, 2015](https://www.ilfattoquotidiano.it/2015/08/05/crac-coopca-la-guerra-dei-soci-che-spaventa-le-altre-cooperative-del-nordest/1919384/)).
- **Enron, 2001.** Not a co-op, but the same trap: about 63% of the money in its workers' 401(k) retirement plan was in Enron's own stock when it collapsed ([GAO-02-480T](https://www.gao.gov/assets/gao-02-480t.pdf)).
Three lessons come out of these. When members sense trouble, they rush to get their money out, and the rush itself can sink the firm. Savings get sold as safer than they are. And help after the fact is voluntary, partial and slow.
## How similar protection works elsewhere
- **Credit unions.** America's credit unions are member-owned, like co-ops, and they insure their members' savings through a fund they pay for themselves. Each credit union keeps a deposit worth 1% of its insured savings with the fund, and the fund aims to hold reserves of 1.2% to 1.5% of insured savings ([12 U.S.C. § 1782](https://www.law.cornell.edu/uscode/text/12/1782)). Unlike this model, that fund is backed by the full faith and credit of the United States ([NCUA](https://www.ncua.gov/support-services/share-insurance-fund)).
- **Bank deposits.** Deposit insurance is paid for by the banks, through premiums that by law must rise with each bank's risk. The fund's legal minimum reserve is 1.35% of insured deposits, and its long-term goal is 2% ([FDIC](https://www.fdic.gov/resources/deposit-insurance/deposit-insurance-fund/); [12 U.S.C. § 1817](https://www.law.cornell.edu/uscode/text/12/1817)). It too has government backing.
- **Unpaid wages in Europe.** Every EU country has to run a guarantee institution that pays workers' unpaid wages when their employer goes insolvent: at least three months' pay (or a set minimum over a longer reference period), up to a ceiling. Employers help pay for it unless the government covers it all, and its money has to be kept out of reach of any employer's insolvency ([EU Directive 2008/94/EC](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32008L0094)).
- **A warning: America's pension insurance.** The federal agency that insures private pensions has charged premiums only loosely tied to each plan's risk. The Congressional Research Service put them at "about half of the market price for the insurance." Companies in trouble let their pension plans fall further behind before going bankrupt and handing them over. By 2004 the program's deficit had reached $23 billion ([Congressional Research Service, 2004](https://www.everycrsreport.com/reports/RL32702.html)). Insurance that doesn't charge for risk invites risk.
## The insurance pool
Every locked co-op belongs to a **members' savings insurance pool**. It protects three things, in this order.
**1. Final wages.** If a co-op fails owing its workers pay, the pool pays up to eight weeks of each worker's unpaid wages and earned time off, and then notice pay the co-op owes ([below](#notice-pay-and-severance)). For a low-paid worker, this is usually the biggest loss, and US bankruptcy law only partly protects it: unpaid wages get priority, but only up to $17,150 per worker, and only after secured lenders are paid ([11 U.S.C. § 507(a)(4)](https://www.law.cornell.edu/uscode/text/11/507)).
**2. Former members' payouts.** When someone leaves, their account becomes a fixed amount the co-op owes them, paid on the account's normal schedule, three to seven years after each credit was made (see [Member Capital Accounts](capital-accounts.md#how-its-paid-out)). They no longer work there and have no say in how it's run, so this is the clearest case for protection. The pool covers these unpaid payouts up to $50,000 per person.
**3. A protected slice of current members' accounts.** A current member's account is different. It's their share of the risk of owning the business, and they help run it. So only a slice is protected: 80% of the first $10,000. That protected slice earns the plain Treasury rate. Only the part of the account that's still at risk earns the extra two percentage points (see [Member Capital Accounts](capital-accounts.md#how-the-account-grows)). Nobody gets paid a premium for risk they don't carry, and every member keeps some skin in the game.
**The limits are per person**, across every co-op they've ever belonged to. An account split in a divorce or passed to an estate shares the original person's limit.
## Notice pay and severance
A worker whose co-op closes can be owed three kinds of money: pay for work already done, pay in place of the notice they should have had, and severance. The laws that require the last two overlap, so a worker could look owed the same weeks two or three times over. The model sorts it out this way.
- **What the laws require.** The co-op's bylaws require 30 days' notice or pay instead, with no exceptions ([When Work Runs Short](downturns.md#the-layoff-order)). Federal law requires 60 days' notice of a closing from employers with 100 or more full-time workers, or 100 or more workers putting in 4,000 hours a week between them ([20 C.F.R. § 639.3(a)](https://www.ecfr.gov/current/title-20/part-639/section-639.3)). One that closes without it owes back pay and benefits for each day short, up to 60 days, but never for more than half the days the worker was employed ([29 U.S.C. § 2104](https://www.law.cornell.edu/uscode/text/29/2104)). Courts disagree on whether that means calendar days or working days. Federal law excuses short notice when the business was seeking money that notice would have scared off, or when the cause wasn't foreseeable ([§ 2102(b)](https://www.law.cornell.edu/uscode/text/29/2102)). California requires 60 days at sites with 75 or more workers, part-time ones included, with back pay on the same terms ([Labor Code §§ 1400.5](https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=LAB&sectionNum=1400.5), [1401](https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=LAB&sectionNum=1401), [1402](https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=LAB&sectionNum=1402)). New York requires 90 days' notice from employers with 50 or more full-time workers there, but, like federal law, limits back pay to 60 days ([Labor Law § 860-g](https://newyork.public.law/laws/n.y._labor_law_section_860-g)). New Jersey requires 90 days from employers with 100 or more, and its penalty for short notice is four more weeks of severance rather than back pay. Other states have their own versions, and the bank's checked entry for each state lists them.
- **Notice pay, once.** A worker gets back pay for the longest period any one law pays, not all of them added up. That's at most 60 days under the federal, California and New York laws. New Jersey's extra four weeks are severance, not notice pay. The laws figure back pay at the higher of the worker's final rate and their three-year average. The pool pays at its own covered rate ([Closing the loopholes](#closing-the-loopholes)), and any difference stays the worker's claim against the co-op.
- **Severance: the larger of the bylaws and state law.** The standard bylaws pay a week per year worked. New Jersey's 2019 law requires the same of employers with 100 or more workers when a closing or mass layoff ends 50 or more jobs at a site, and counts federal back pay toward it ([P.L. 2019, c. 423](https://pub.njleg.state.nj.us/Bills/2018/PL19/423_.HTM)). New Jersey has amended it since, reportedly to count more workers and sites, and the bank's entry has to be checked against the current text. The bylaws never add on top of the law; they only fill in where the law pays less.
- **What the pool pays, in order.** Out of each worker's eight weeks, first pay for work already done and earned time off, then notice pay, including the co-op's usual health contribution for those weeks. Pay from other work in the same weeks is taken off the notice pay, so nobody is paid twice for the same weeks. The pool covers notice only up to the standard bylaws' 30 days or the period the law actually requires, whichever is longer. A co-op can promise more, but the pool won't pay for it, and a longer notice period or bigger severance adopted in the two years before a failure gets neither cover nor priority. Severance isn't covered. France's fund covers it, but this pool is smaller and has nothing behind it.
- **The pool takes over the claim it paid.** Federal law subtracts wages the employer paid for the notice days, but the pool isn't the employer, and no court has said whether its payments count. So the pool pays as an advance on the co-op's behalf, and each worker assigns to it, in writing, their notice-pay claims up to what it paid.
- **Unemployment benefits.** In some states, pay in place of notice delays unemployment benefits for the weeks it covers. There, part of what the pool pays saves the state's fund money rather than adding to the worker's income. The pool pays notice pay in whatever way costs workers the least benefit in each state, and the bank's checked entry says what that is.
- **Skipping notice isn't following the rules.** Directors who closed without the notice the bylaws or the law required, when no legal exception applied, are among the people the pool pursues ([Owners Are Still Workers](labor-law.md#when-wages-go-unpaid-members-can-owe-them)).
- **Much of the severance is at risk in a failure.** Bankruptcy puts wages, severance and notice pay earned in the 180 days before filing ahead of other unsecured debts, up to $17,150 per worker ([11 U.S.C. § 507(a)(4)](https://www.law.cornell.edu/uscode/text/11/507)). Courts often treat only the part of length-of-service severance earned in those 180 days as having that priority, so a long-serving worker's week-per-year is mostly an ordinary claim that may pay little. Back pay a court awards for notice-law violations covering days after the filing can be paid ahead of that, as a cost of the case, if the court finds it won't cost other jobs ([§ 503(b)(1)(A)(ii)](https://www.law.cornell.edu/uscode/text/11/503)). A trustee that only liquidates the business may owe no federal notice at all. The pool's claim for what it paid ranks behind the worker's own unpaid claims ([When a Locked Co-op Goes Bankrupt](bankruptcy.md), point 7).
- **A co-op buyer that keeps the workers owes no notice pay for them.** When a business is sold, its workers count as the buyer's employees from the day of the sale, so those who keep their jobs haven't lost them under federal notice law ([29 U.S.C. § 2101(b)(1)](https://www.law.cornell.edu/uscode/text/29/2101)). The failed co-op still owes notice pay to those the buyer doesn't take on, and the buyer owes notice for any later closing.
- **Splitting doesn't avoid it.** Federal rules can treat related companies as one employer, looking at common ownership, common directors, actual control, shared personnel policies and dependence on each other ([20 C.F.R. § 639.3(a)(2)](https://www.ecfr.gov/current/title-20/part-639/section-639.3)). Co-ops in a federation share standard bylaws, a lender and a pool, so a court might count them together. That's unsettled. So the bylaws don't rely on the answer: a co-op whose group reaches 100 workers, counting companies it controls and co-ops split from it in the last three years, gives 60 days' notice as if federal law applied, and layoffs within any 90 days are added together. Neither the bank nor the pool may direct a co-op's layoffs, which would make the case for counting co-ops together stronger.
## How it's paid for
**By the co-ops, not taxpayers.** The pool has **no government backing**, for the same reason [the bank's](pilot-spec.md#the-decision-rule) debts don't. A public guarantee would be a hidden subsidy, and markets would come to assume it. That's a real difference from credit unions and banks, and it's why the pool has to be run more cautiously than they are. The charter also bars the bank from lending to the pool, buying its assets or paying its claims, so the bank can't become a backstop by the back door. No law can stop a future Congress from bailing the pool out. The best this model can do is keep the promises small enough, and the cut in a bad year plain enough, that a bailout isn't needed.
- **A deposit to join.** Like a credit union, each co-op keeps a refundable deposit with the pool, worth 1% of what's covered.
- **Yearly premiums, priced as if nothing will be recovered from members' savings.** Members' accounts rank behind the co-op's lenders, so when a co-op fails, there's usually little or nothing left for them. Premiums assume exactly that. They're set by an independent actuary from a published formula, and audited every year in both directions, like [the bank's rates](pilot-spec.md#what-market-rate-means).
- **Final-wage cover is one flat rate for everyone,** as in Germany and France. Charging the riskiest co-ops more would charge care workers most for the cover that matters most to them.
- **Savings cover is priced on things a co-op controls,** like the size of its reserve, how much it owes, and whether its accounts are on time, measured from its audited year-end accounts. Margins set by public payment rates, like Medicaid's, aren't counted against it. The premium can rise by at most one percentage point a year, unless audited accounts show the co-op getting worse.
- **Deferring is limited.** A struggling co-op can defer up to one year's premium, as a debt to the pool. While it does, new additions to its members' accounts aren't covered, and the pool watches its accounts monthly. Deferred premiums count as nothing in the pool's reserve, and if the co-op fails, they're paid ahead of its members' accounts.
- **Who pays.** Current members pay for cover of their own slices, out of their account interest, in proportion to each person's protected balance. Members who take all their profit share in cash, as low-income members can, don't pay for cover they don't have. The co-op as a whole pays for final-wage cover, since everyone is covered, and for former members' payouts. Those are debts the co-op owes, and charging the premium to former members would take most of the interest on money they can't touch.
- **Other ways to cover former members.** A co-op can instead put former members' unpaid payouts in a fully funded escrow account, or back them with a surety bond from an approved insurer. The pool is the default. If private options come in cheaper, that's a useful check on the pool's prices.
- **The reserve is kept safe.** It's held in Treasury securities by an independent custodian, out of reach of any co-op's creditors, as Europe's rules require of wage funds. It never holds the bank's loans or anything issued by co-ops, which would fail at the same time as the co-ops it insures. The one exception is what it's owed back after a failure: when it hands the wage claims it paid to a workers' co-op buying the failed one ([When a Locked Co-op Goes Bankrupt](bankruptcy.md), point 7), the new co-op's debt to it is counted as worth nothing in the reserve until it's repaid, and can't be more than the wages the pool paid in that failure.
## What it should cost
Nobody has priced insurance quite like this, but the pieces can be estimated from what's known.
**Final wages and notice pay: about 0.55% of payroll.** Europe's wage guarantee funds are the closest match. Both are paid for by employers as a share of payroll:
- **Germany** pays up to three months of unpaid wages when an employer goes insolvent. Employers paid 0.06% of payroll in 2023 and 2024, and pay 0.15% in 2025 and 2026. After the financial crisis the rate jumped to 0.41% in 2010 ([lohn-info.de](https://www.lohn-info.de/insolvenzgeldumlage.html); [Haufe](https://www.haufe.de/id/beitrag/insolvenzgeldumlage-fuer-2026-HI17106952.html)).
- **France** covers wages and other amounts owed to workers, including severance, up to a ceiling. Employers pay 0.25% of each worker's pay, up to about €16,000 a month, in 2026 ([Service Public](https://entreprendre.service-public.gouv.fr/actualites/A17906)). In 2009 the rate rose from 0.10% to 0.20%, 0.30% and then 0.40% within a single year ([Eurojuris](https://www.eurojuris.fr/articles/augmentation-du-taux-de-cotisation-ags-au-1er-octobre-2009-10360.htm)).
Both are run or required by the state and spread across every employer in the country, so their rates are a floor, not a benchmark. This pool covers less, eight weeks of wages and notice pay, but it covers young, small firms in a few industries, with nothing behind it. At 0.25% of payroll, the rate would quietly assume that a co-op that fails owes its workers, on average, about two of the eight weeks, since most businesses keep paying wages until they close. Notice pay changes that: a co-op that closes without notice owes about four weeks of it under its own bylaws, on top of the last unpaid pay period, and failing businesses rarely give notice in time. Pay from other work cuts it somewhat. So the pool starts by assuming about five of the eight weeks, at about 0.11% of payroll per week owed, which comes to 0.55% of payroll, plus a charge to build its reserve, and the actuary adjusts from there. The rate is the same for every co-op, as with wage cover in Europe, so older co-ops, which fail less often, help pay for younger ones. That's a choice, made so the newest co-ops aren't charged most for cover their workers need most. Nobody has measured any of this for small US firms. If a failed co-op owed all eight weeks, the premium would need to be about 0.9% of payroll. The pool records what it paid, and for what, in every failure.
**Members' savings: about 3% to 6.5% of covered amounts a year, depending on the co-op's age.** When a co-op fails, members' accounts and unpaid payouts rank behind its lenders, so the pool should expect to pay almost the whole covered amount. What matters is how often covered co-ops fail owing more than they have. Three rough numbers bracket it:
- **Closures of firms like these.** The Census Bureau counts every US employer that closes all its locations, each year, by the firm's age and size ([Business Dynamics Statistics, firm age by size, 1978–2023](https://www2.census.gov/programs-surveys/bds/tables/time-series/2023/bds2023_fa_fz.csv)). Selling or merging doesn't count as closing. Among firms with roughly 10 to 200 workers, the share that closed each year, averaged over 1990 to 2023, falls with age:
| Firm age | Closed each year |
| --- | --- |
| 3 | 8.0% |
| 4 | 7.0% |
| 5 | 6.4% |
| 6 to 10 | 4.9% |
| 11 to 15 | 3.7% |
The Bureau sorts firms by their average workforce over two years, so a firm that closes is filed at half its size. These figures count closures filed at 5 to 99 workers against firms filed at 10 to 99. Counting only closures filed at 10 to 99 would give less than half as much, which is too low. Many of the closures filed at 5 to 9 are firms that had already been shrinking, so the figures lean a little high for a healthy co-op. Figures for business locations rather than firms tell a similar story: of those opened in 2013, about 61% were still open after three years, when account cover starts, and roughly 7% to 10% closed each year after that ([Bureau of Labor Statistics](https://www.bls.gov/bdm/us_age_naics_00_table7.txt)).
- **Not every closure is a failure.** In Census Bureau data, about a third of owners of closed businesses said the business was successful when it closed ([Headd, Census Bureau, 2001](https://ideas.repec.org/p/cen/wpaper/01-01.html)). But many "successful" closures were owners retiring or selling, which a locked co-op can't do, and members, ranking last, can lose their savings even when the lenders are paid. So the pool counts 80% of closures as claims, not two-thirds, until its own data says otherwise.
- **Loan defaults.** Among the SBA's mature loan groups, loans making up 6% to 10% of the money lent eventually defaulted ([GAO-20-618](https://www.gao.gov/products/gao-20-618)). Spread over the life of the loans, that's roughly 1% a year. That's close to a floor: members can lose their savings in failures where the lender still gets paid.
Worker co-ops may do better. The best study found them about 29% less likely to close, in Uruguay ([Burdín, 2014](https://journals.sagepub.com/doi/10.1177/001979391406700108)). But the pool shouldn't count on that until its own data shows it. So it starts at 80% of the closure rate for the co-op's age: about 5% to 6.5% a year at ages three to five, when account cover starts, about 4% at six to ten, and about 3% after that. Within each age, a strong co-op, with a large reserve and little debt, pays less, a weak one more. These are starting guesses from rough data, and the pool publishes every year how its actual losses compare.
**That's expensive.** The protected slice of a current member's account earns only the plain Treasury rate. The premium takes most of that interest at a mature co-op, and more than all of it at a young one. That's why current members' cover comes last, is optional, and is priced on the assumption that the co-ops most likely to choose it are the weaker ones. It may never be worth offering.
## Starting small
A few hundred co-ops, all started in the same few years, can't spread this risk well. They're all at the riskiest age, and one recession hits them together. So cover comes in stages:
- **From the start: final wages and former members' payouts,** for every covered co-op. These protect people with no say in how the co-op is run, and final wages are the biggest loss for low-paid workers.
- **Current members' slices come later,** once the pool has seen about 1,000 years of co-op experience (say, 200 co-ops for five years) and its savings reserve has reached its target. Then each co-op can choose it, but only in a yearly window, based on audited accounts, with cover starting two years later. The pool can refuse a co-op that's showing warning signs. A co-op that opts out can't opt back in for five years. That stops co-ops from joining only when they're in trouble.
- **Limits follow the reserve, not the calendar.** Every year, the limits are set by a published formula from the reserve the pool actually has, compared with what a bad recession would cost ([the formula](#how-big-the-reserve-has-to-be) is below). If covered amounts grow faster than the reserve, the limits wait.
## How big the reserve has to be
Failures come in waves, and the waves last. US business bankruptcy filings ran about 22,000 a year in 2018 and 2019. In the last big recession they rose to about 43,500 in 2008, 60,800 in 2009, 56,300 in 2010, 47,800 in 2011 and 40,100 in 2012 ([American Bankruptcy Institute](https://abi-org.s3.amazonaws.com/Newsroom/Bankruptcy_Statistics/Total-Business-Consumer1980-Present.pdf); [US Courts](https://www.uscourts.gov/sites/default/files/statistics_import_dir/1209_f2.pdf)). Over those five years, that's about six normal years' worth of extra failures. And most small businesses that close never file for bankruptcy at all.
Closures tell a milder story. In the same Census data, closures of firms like these rose by about half a point in 2008 and 2009, and by about a point for firms three to five years old, and were back down by 2010. The early 1990s were worse. What rises in a recession is the share of closures that leave debts unpaid, which is what bankruptcy filings count, and that's what the pool pays for. So the real wave for the pool is somewhere between: closures say almost no extra years, bankruptcies say six.
A pilot's co-ops are all young and concentrated in a few industries and regions, which makes a wave worse. So:
- **The target for savings cover is six extra years' worth of expected claims while the pool is young,** meaning until it has 1,000 years of co-op experience and no one industry in one state holds more than a quarter of what's covered. After that it's four years' worth, about 15% of covered amounts for mature co-ops. That compares with the 1.2% to 2% that government-backed funds aim for. Nothing stands behind this pool, so its reserve has to carry a long recession on its own. It doesn't count on reinsurance, which may not exist for this kind of risk.
- **For final wages, about 10% of covered wages,** by the same reckoning.
- **Building it takes time.** Premiums carry an extra point a year for savings cover, and an extra 0.1% of payroll for wage cover, until the targets are reached. For savings cover that takes about fifteen years for mature co-ops if the pool doesn't grow, and a young pool won't reach its target during the pilot at all. That's why limits follow the reserve.
- **Surcharges wait for the recovery.** After a bad stretch, premiums can rise by up to one point of covered amounts for up to three years. But the surcharge starts only once national unemployment has stopped rising, so it doesn't hit co-ops at the bottom of a recession. The surcharge is paid by the co-ops that survived. Until then, if claims outrun the reserve, each person's first $2,000 is paid in full, and everyone is paid the same share of the rest, as below.
- **Concentration is limited by limits, not refusals.** If one industry in one state grows past a quarter of what's covered, cover limits for new additions there are lowered. No co-op is refused cover because of its industry.
**The formula for limits.** The inputs are written into the charter so the actuary can't pick friendlier ones: the Census closure rates by age for 1990 to 2023, at 80%, until the pool has 1,000 years of its own experience. After that, its own record can lower the rates by at most a quarter. An outside actuary signs off on the inputs, not just the result.
- **The reserve has a planned path:** what the build charges should have produced by now if claims had run as expected. Only the reserve counts, not the refundable deposits or future premiums, which are priced to pay normal claims.
- **If the reserve falls behind the path,** limits for new additions fall in proportion, and the cut applies back to everything added since the last yearly valuation, so nobody can rush additions in ahead of it. Amounts covered before that keep their cover.
- **Limits come back slowly:** by at most a tenth a year, and only after two years on the path.
- **No switching back and forth.** A co-op that moves its former members' cover to escrow or a surety bond can't return to the pool for five years, and the pool prices it when it does.
**A bad fifth year.** Suppose a recession starts in the pilot's fifth year. The covered co-ops are three to five years old, priced at about 5.7% a year on average. The reserve is small, about 3% of covered amounts, because most co-ops joined recently. Five bad years at that rate, plus four extra years' worth, come to about 51% of covered amounts. Premiums bring in about 27%, counting a fifth of the paying co-ops lost along the way. With the reserve, that pays about 58 cents on every dollar claimed. With six extra years' worth, or if every closure turns out to be a claim, it's closer to 45 to 50 cents. The industry with the most co-ops is also stressed on its own, at twice its normal rate. For final wages and notice pay, if a failed co-op owes the five weeks the rate assumes, the pool pays about three-quarters. If it owes all eight, it pays a bit under half. So in its first years the pool is a partial promise. Each member's yearly statement shows what the pool would pay per dollar in this stress case, as it stands that year.
**What it costs, in the standard example.** A mature 40-person co-op might have $300,000 of protected account slices (at most $8,000 a member, and less for members who take their share in cash), $200,000 of former members' payouts outstanding, and about $2 million a year of payroll, with eight weeks of it, about $300,000, covered by final-wage cover.
| Cover | Premium | A year |
| --- | --- | --- |
| Final wages and notice pay | 0.55% of payroll, plus 0.1% to build the reserve | about $13,000 |
| Former members' payouts | 3%–4% for a co-op past its tenth year, plus 1 point to build the reserve | $8,000–$10,000 |
| Current members' slices, once offered (after the reserve target is reached), if the co-op chooses | 3%–4% | $9,000–$12,000 |
| **All three** | | **$30,000–$35,000** |
That's about 5% to 6% of the $600,000 annual profit in the [capital accounts example](capital-accounts.md#where-each-years-profit-goes), or about 3.5% to 4% without cover for current members. A strong co-op would pay less, a weak one more. Once the reserve reaches its target, the extra charges stop. The real rates will come from the pool's actuary and its own experience.
## Who runs it, and how claims are paid
- **A federal charter.** The pool is created by the same federal law as the bank, with a named federal regulator, capital standards, and a clause saying state insurance law doesn't apply to it. Otherwise it would need a license in every state, and federal law generally leaves insurance to the states unless Congress says otherwise.
- **An independent board.** Most of its members are independent (actuaries, former insurance regulators, people with no stake in any co-op), appointed by a body separate from the bank, for fixed staggered terms, and removable only for cause. A minority are elected by covered workers. None represent the co-ops that pay premiums, so the biggest co-ops can't keep their own premiums low at everyone else's expense. Board members step aside from anything involving their own co-op. Limits on premiums are written into the charter, and a co-op can appeal its rate to the independent adjudicator.
- **Checked every year.** An outside actuary publishes an opinion on the pool's reserves, and federal auditors audit it. The pool and the bank are stress-tested together, since the same recession would hit the bank's loans and the pool's claims at once. If the reserve falls below its target, the board publishes a plan to restore it within 90 days, and limits stop rising until it does.
- **One pool, one register.** Federations can run their own pools only once each meets the national pool's reserve ratio on its own, since pools of co-ops all lose in the same recession. There's one national register of everyone covered, so the per-person limits hold across pools.
- **Automatic payment.** When a covered co-op fails, the pool pays from the co-op's own member register and payroll records. Nobody has to know to file a claim. Fagor's former workers lost their case partly because they filed too late. Disputes go to an independent adjudicator. Final-wage payments are wages, so the pool or the liquidator pays them through payroll, with the usual withholding.
- **The pool steps into the workers' shoes.** Once it pays wages, it takes over the workers' priority claims in bankruptcy ([When a Locked Co-op Goes Bankrupt](bankruptcy.md)) and under federal and state notice and wage laws, so it recovers what it can. Workers are never paid twice for the same weeks ([Notice pay and severance](#notice-pay-and-severance)). Federal law and some states can make a company's officers or owners personally liable for unpaid wages, and New York appears to make every co-op member liable ([Owners Are Still Workers](labor-law.md#when-wages-go-unpaid-members-can-owe-them)). In a co-op, those are workers, so the pool doesn't pursue ordinary members or officers who followed the rules, only people who hid losses or kept others working after a missed payroll.
- **Stepping in early.** Every covered co-op sends the pool quarterly accounts. If warning signs appear, the pool can stop covering *new* additions to accounts until the co-op recovers, which stops members from piling up covered savings in a sinking firm. That freeze also applies to anyone who leaves after it starts, so members can't escape it by quitting.
## Closing the loopholes
- **No paying out phantom profits.** Before paying, the pool's own auditor or the liquidator rechecks the last three years of accounts, and for former members, the years since they left. Losses that were hidden or booked late are charged to accounts first. A covered balance can never be more than the allocations reported to the IRS.
- **No quitting to upgrade your cover.** A current member's slice is covered up to $8,000, and a former member's payout up to $50,000. So for the first three years after someone leaves, their payout is covered only like a current member's slice. Anyone who left before a failure has their balance recalculated to share the losses of the years since they left. Someone rehired within three years goes back to current-member cover. And if an unusual share of the co-op's accounts leaves in one year, those departures get only current-member cover.
- **No stuffing accounts.** Amounts added to accounts in the year before a failure are covered only up to the co-op's normal split, meaning its average over the previous five years.
- **No inflated final wages.** Covered pay is the lower of a worker's average over the previous 12 months and what appears in the co-op's payroll tax filings. Paid time off counts only at the co-op's normal accrual. In a co-op's first year, final-wage cover is four weeks, not eight.
- **No paying insiders first.** Loans that directors or members guaranteed personally, repaid in the year before a failure while wages went unpaid, are clawed back.
- **No cover for brand-new shells.** Account cover starts after a co-op's first three years of checked accounts and an audit by the pool itself ([What the Rules Cost a Small Co-op](compliance.md#what-a-review-can-and-cant-stand-in-for)), and every covered co-op must pass the same checks as bank borrowers: real members doing most of the work, verified against payroll records (see [Guarding the Pilot](pilot-safeguards.md#the-hidden-boss)).
- **No trading claims.** Covered claims can't be sold or transferred, except in a divorce or to an estate.
- **No underreporting.** Payouts are based on the lower of what the co-op reported and what it actually owed. Unpaid premiums are charged to directors and to uncovered balances, not to protected ones, and directors who file false reports are personally liable.
## What the pilot records
The pool files its pricing model before the first co-op is covered, and each year publishes its predicted losses against its actual ones. For every claim, it records the covered amount at failure, what was recovered, and how long payment took, by type of cover. It also publishes which co-ops deferred premiums and which chose current-member cover, and compares wage losses with those at the matched conventional firms in [the pilot](pilot-spec.md#who-co-ops-are-compared-against). That's how the next design gets real numbers instead of these.
## No selling savings as something they're not
Both Fagor and the Italian co-ops got into trouble partly because members' money was held in things that looked like bank deposits. So:
- **A locked co-op can't take savings deposits** from its members, or sell them bonds or other investments. A member's only money in the co-op is their capital account.
- **Every member gets a plain statement each year** of how much of their account is protected, how much isn't, and what the pool can and can't promise.
## What it can't promise
- **A wave of failures could outrun the pool.** Co-ops in the same region or industry tend to fail together, in the same recession. The reserve target is sized for a long recession, but it takes years to build, and in the pilot's early years a bad recession would leave the pool paying roughly 45 to 60 cents on the dollar. If claims exceed what the pool has, it pays each person's first $2,000 in full and everyone the same share of the rest at once, then pays the rest as money comes in. That rule is published in advance, and members are told about it every year. This model gives the pool no government backing, so it can't promise more. **It's insurance, not a guarantee.**
- **It starts small.** It will be weakest in its early years. That's why coverage phases in, current members' slices come later, and account cover starts only after three years.
- **Members pay for it, and it isn't cheap.** Premiums come out of account interest and profit. Insuring current members' savings could take most of the interest on the protected slice, which is why co-ops can choose not to. That's the honest price of making savings safer.
- **Bankruptcy law may not cooperate.** Courts can push former members' claims to the back of the line, behind other creditors, which makes the pool's recoveries even smaller. That's another reason it assumes nothing comes back.
- **The legal form needs Congress.** Without the federal charter, a pool like this is insurance under state law, needing a license in every state. Whether capital accounts count as securities is also unsettled ([Member Capital Accounts](capital-accounts.md#are-accounts-securities)). Both are listed in [Open Problems](open-problems.md).
+147
View File
@@ -0,0 +1,147 @@
---
title: "After a Pass: How Big, How Fast"
section: Growing the Sector
order: 68
summary: Even if the pilot passes, a co-op sector big enough to change how other employers treat workers is decades away nationally, so growth comes in capped stages sized to audited demand, and the labor-market effect gets its own test in a few local markets.
---
# After a Pass: How Big, How Fast
The pilot tests one thing: can worker co-ops compete, survive and repay market-rate loans ([The Test](the-test.md))? That's the case for the bank, and it stands on its own: people get to govern their own workplaces, and the public gets its money back. The bigger hope is different. If co-ops become common enough, conventional employers would have to treat workers better or lose them to co-ops. That's the **labor-market effect**. The pilot can't test it, because about 400 co-ops employing perhaps several thousand people won't move anyone's wages.
This page works through what comes after a pass: how big the sector would have to get, how long that could take, how to grow without lowering standards, and how to find out whether the effect is real without waiting decades or promising it in advance.
## How big is big enough?
Nobody knows. But three kinds of evidence give a sense of it, and they don't all point the same way.
**When big retailers raised their own pay, other employers didn't follow.** From 2014 to 2023, large retailers like Amazon, Walmart and Target set their own company-wide minimum wages. Those covered over 3 million jobs. A study using payroll records for thousands of firms found the policies raised pay and cut turnover at the companies that adopted them. But the effect on other employers' pay, even ones that hired from the same workers, was "precise" and "economically negligible." The authors' explanation: the retailers mostly kept their workers longer rather than hiring more, and other employers could still find plenty of people ([Derenoncourt and Weil, 2024](https://www.nber.org/papers/w32546)).
An earlier version of the Amazon research did find that other employers raised pay too, and it's still widely quoted. Its own authors later found the result came from a flaw in their method, a statistical effect called mean reversion, and with revised methods they found no evidence of it ([Derenoncourt and others, revised 2022](https://www.nber.org/papers/w29425)).
**When one employer hired a lot in a tight market, others did follow.** The same study describes a different case. When Veterans Affairs hospitals raised pay for registered nurses, nurses' pay at nearby hospitals rose too. The authors think the difference is that nurses are a small, concentrated market, while low-wage retail work is not. So an employer that adds real demand for scarce workers can move others' pay, and one that just keeps its own workers longer mostly doesn't. New co-ops create new jobs, which puts them closer to the nurses' case, but only if they're a big enough share of the market.
**Unions once lifted pay for non-members, at much higher densities.** In 1977, about 22% of American private-sector workers were in unions. By 2015 it was 7%. One study estimates that if unions were still as common in each occupation and region as in 1977, non-union men would earn about 6% more, around $3,200 a year, and non-union women about $900 more. That's the "threat effect": employers paid more to keep unions out. It showed up where unions were dense in a particular occupation and region, not as a national average. The study compares places and times, so it can't prove cause and effect ([Denice and Rosenfeld, 2018](https://doi.org/10.15195/v5.a23)). And co-ops don't work the same way. A union threat happens inside your current job. A co-op only helps workers who can leave for one or buy their own workplace. So this suggests a scale, not a target: an effect like that came with about one private-sector worker in five.
**The biggest co-op sectors are still small shares.** Mondragon is the largest employer in the Basque Country ([Co-operative News](https://www.thenews.coop/mondragon-worker-co-op-federation-reports-sales-of-e11-213bn/)), and about 30,660 of its 70,000 workers were there in 2024, not all of them members ([Wikipedia, citing Mondragon's 2024 annual report](https://en.wikipedia.org/wiki/Mondragon_Corporation)). That's about 3% of the roughly 995,000 people working in the Basque Country ([Eustat, via Orain](https://orain.eus/es/actualidad/economia/2025/01/21/las-personas-ocupadas-bajan-0-2-y-tasa-de-paro-se-mantiene-en-7-1-pero-con-1100-parados-menos/)), after almost 70 years. France, with one of the best-supported worker co-op sectors in the world, counted 87,699 people working in its worker co-ops and a related form of community co-op in 2024 ([ESS et société, reporting CG Scop](https://www.ess-et-societe.net/Les-Scop-et-Scic-poursuivent-leur-croissance-avec-87-699-salaries-4-et-un-CA-de-10-2-milliards-d-EUR-6)).
So the honest answer: any effect is likely to show up locally, in particular jobs in particular places where co-ops are a big share, long before it shows up in national numbers. Whether it shows up at all is what the local test below is for.
## How long could it take?
America had about 136 million private-sector jobs in August 2026 ([Bureau of Labor Statistics](https://www.bls.gov/news.release/empsit.t17.htm)). One percent of that is about 1.36 million.
Nobody knows how many people work in American worker co-ops today. The sector's census counted about 751 co-ops in 2023, triple the number a decade earlier ([NCEO summary of the Democracy at Work Institute census](https://www.nceo.org/employee-ownership-blog/democracy-at-work-institute-releases-its-2023-worker-cooperative-state-of-the-sector-report)), but only 242 firms answered its latest survey, too few to add up workers ([US Federation of Worker Cooperatives](https://www.usworker.coop/census/)). So suppose, as a round assumption, that 20,000 people work in them by the pilot's year 12. To reach 1% or 3% of today's private jobs:
| If the sector grows by | 1% of private jobs in | 3% (Mondragon's Basque share) in |
| --- | --- | --- |
| 12% a year, about the pace at which the number of co-ops tripled over the last decade | about 37 years | about 47 years |
| 25% a year, twice that pace, every year | about 19 years | about 24 years |
These are minimums. The number of jobs keeps growing, so the target keeps moving. The 12% pace comes from the number of co-ops, not the number of workers. And fast growth is hard to sustain. Starting from fewer than 20,000 workers makes every figure longer.
So nationally, even a clear success likely means decades before co-ops are common enough to matter to most workers. That's the price of not forcing it, and I'd rather say so now than have someone discover it later.
## What fast growth has looked like
**Rural electric co-ops grew fast, in conditions this model doesn't have.** In the mid-1930s, nine in ten rural American homes had no electricity. By 1953, over 90% of farms had it. The federal Rural Electrification Administration lent to co-ops of farmers who wired the countryside themselves, and two years after it started there were already 350 co-op projects in 45 states ([EH.net](https://eh.net/encyclopedia/rural-electrification-administration/); [America's Electric Cooperatives](https://www.electric.coop/our-organization/history)). Two things made that speed possible. Private utilities had refused to serve those areas, so the co-ops had no competitors. And the loans were cheap: 25 years to repay, and from 1944 an interest rate fixed at 2%, whatever the market charged. This model has neither. Co-ops compete with established firms in every industry, and they pay market rates. The electric co-ops also carry a warning. They built defenders in almost every congressional district, and critics can fairly say those defenders kept cheap loans flowing long after the original job was done ([Making It Last](durability.md)).
**Employee ownership with tax breaks grew big, in a different form.** In 2023, about 6,400 American companies had employee stock ownership plans (ESOPs), with 10.9 million active participants ([NCEO](https://www.nceo.org/research/employee-ownership-by-the-numbers)). That's equal to roughly 8% of private-sector jobs, though participants aren't the same as jobs, and a plan can own only a small slice of its company. It's the biggest American case of ownership spreading through ordinary finance, and it took 50 years and large federal tax breaks. ESOPs don't give workers one member, one vote. I haven't found any study of whether they lift pay at other employers.
## Growing in stages
If the pilot passes, the charter sends Congress a fast-track vote on more capital ([The Pilot, in Numbers](pilot-spec.md#the-decision-rule)). Here's how the growth after that is meant to work:
- **Each stage is sized to audited demand, not a target.** The bank can ask for enough capital to fund the good applications it had to turn away for lack of money, plus at most 20%. Only unique applicants count, only those that met the bank's filed lending standards, and the count is certified by the independent reviewers, not the bank ([Making It Last](durability.md)). Delays don't count. There are still no lending or job targets. An institution with a target lowers its standards to hit it, which is what sank Venezuela's co-op push ([The Pilot, in Numbers](pilot-spec.md#two-warnings-from-real-life)).
- **Capped growth.** No stage can more than double the bank's capital. A bank that suddenly has to find ten times as many borrowers will lower its standards without meaning to.
- **Demand can't be starved, either.** Applications and approval rates are published against the pilot's own. A hostile administration can't cut outreach, tighten what counts as "good," and then claim nobody wants to borrow, because the reviewers already re-score rejected applications every year and publish how many met the standards anyway ([Making It Last](durability.md)).
- **The same rules at every stage.** Market rates, the same underwriting, the same limits by industry and county, and no federal guarantee on anything the bank borrows.
- **Growth follows the evidence for each kind of work.** Lending to co-ops in lower-paid work is sized from that group's own result, and the part held for the lower tier from that tier's result. If the pilot had too few of them to tell, that lending can't grow faster than the rest ([Who the Bank Lends To](who-gets-loans.md#results-by-group)).
- **The same measures, published for every new group of co-ops.** Each later round of capital is its own vote in Congress, so every Congress sees how the newest co-ops are doing, not just the pilot's. A passed test shows that co-ops funded one way, at one size, could compete. It doesn't show the same is true at a larger volume, when the bank has to reach further for borrowers.
- **The full public cost, every year.** The bank publishes its all-in cost to taxpayers per co-op job: losses, running costs, all business help, and what the public's capital could have earned in Treasury bonds instead.
## Testing the labor-market effect
The pilot can't test it. But once the sector grows, it can be tested locally, without waiting decades and without deciding anything in advance.
The model is a French experiment that started in 2007. Researchers wanted to know whether job counseling for young unemployed people really created jobs or just moved them from one job seeker to another. They randomly chose what share of eligible people would get counseling in each of 235 local labor markets: 0%, 25%, 50%, 75% or 100%. The counseled did find stable jobs faster. But the gains were short-lived, and partly came at the expense of people in the same markets who weren't counseled, especially in weak markets ([Crépon and others, 2013](https://www.nber.org/papers/w18597)). A test of individuals alone would have missed that.
The same idea can test co-ops.
### Where
- **A few kinds of work** where the pilot showed co-ops can compete. At least one should be work where employers really set pay. In home care, for example, pay depends heavily on what Medicaid reimburses, so employers may not be free to respond. Where that's true, changes in reimbursement are measured and controlled for.
- **Local labor markets drawn by lottery,** from a pool whose rules are written into the charter: labor markets defined by commuting patterns, with 2,000 to 4,000 private-sector jobs in the chosen kind of work (big enough to measure, small enough that co-ops could plausibly hold a real share), and local governments that opt in. A market counts as opted in only if the governments covering most of its workers do. The draw is public and made within states, so markets are compared with others under the same state minimum wage and Medicaid rates. Markets that border a high-help market are left out of the comparison, because workers and employers cross those lines. Its random seed is published in advance. Members of Congress can't add or remove places.
- **Help or none.** Half the markets get a high level of outreach and [help before the first loan](before-launch.md), including outreach to owners who might sell to their workers ([When an Owner Wants to Sell to the Workers](buyouts.md)). The other half get none. The French experiment used several levels, but a middle level would cost a third of the budget and, with this few markets, add almost nothing to what the test can detect. The help runs for five years. The charter sets a minimum budget, so a hostile administration can't quietly starve it: $300 a year for each job in the chosen kind of work in the market, in today's dollars, rising with inflation. That's my first guess, sized in [the numbers below](#the-numbers-in-a-first-draft). It's paid from its own appropriation, not from loan money, and counted in the bank's published cost per co-op job.
### What doesn't change
**Lending.** Loans in the chosen markets go through the same underwriting, at the same rates, under the same limits by industry and county as everywhere else. Only the outreach and help are concentrated. If the concentration the test needs would break those limits, the limits win, and the test is smaller.
### What gets measured
- **What happens to co-ops.** Their share of the work in each market, with a minimum share filed in advance that counts as "reached." New co-ops and buyouts of existing firms are counted separately. A buyout changes who owns a job but adds no new demand for workers, and the evidence above suggests new demand is what moves other employers' pay. Co-ops that move in from outside the market don't count.
- **What happens to conventional employers** in the same kind of work: pay, turnover, hours, and working conditions where records exist, like injuries.
- **Who gains.** Results by gender, race and ethnicity, and co-op membership compared with the local workforce. Most home care and cleaning workers are women, many of them Black, Latina or immigrant workers, and the union evidence found much smaller gains for women.
- **Whether co-ops actually let people in.** Co-op job openings, how long it takes new hires to become members, and the share of work done by non-members. A large co-op sector that doesn't hire is no outside option for anyone.
- **The costs.** Total jobs in the market, conventional firms closing, and prices where they can be measured, so that higher pay bought with lost jobs or higher prices shows up.
**The data.** Government wage records, like the Census Bureau's Quarterly Workforce Indicators, which report pay, hiring and turnover by industry, place, gender, race and ethnicity. The detailed records are used only inside secure federal research centers, and nothing is published that identifies a business. Co-op borrowers agree, as a loan condition, to have their records linked. Home care and cleaning include a lot of work that wage records miss, like independent contractors and households paying workers directly, so worker surveys fill in. An independent team named in advance does the analysis, and its data access can't be revoked by the administration. Pay is tracked for at least five years, because an incumbent could raise pay briefly to stop co-ops forming and cut it once they're gone.
### Filed in advance
The markets, the kinds of work, the measures, the analysis and a power calculation are filed before the draw. The power calculation states the smallest effect the test could detect. If that's bigger than any effect that's plausible, the test isn't run, because an expensive test that can't find anything isn't worth running.
### The numbers, in a first draft
These are my own rough figures, from public data. The filed version is redone by the independent team with the detailed records. On these figures, **the test fails its own rule.**
- **How noisy pay is.** I took the Bureau of Labor Statistics' county pay records for home health care, services for elderly and disabled people, and commercial cleaning ([BLS county data, 2014–2024](https://www.bls.gov/cew/downloadable-data-files.htm)). In counties with 2,000 to 10,000 private-sector jobs in the work, average weekly pay rose by quite different amounts over five years, both from 2014 to 2019 and from 2019 to 2024. Comparing counties in the same state, the typical spread (the standard deviation) was about 8% to 11%. Past growth didn't predict future growth. Weekly pay mixes hourly pay with hours worked and with who's employed, so the Census records, which can follow the same workers over time, could be less noisy. I haven't been able to check that.
- **The smallest effect the test could find.** With a 9% spread and 100 markets, 50 with help and 50 without, the test would reliably detect a difference of about 5% in pay at employers other than co-ops. "Reliably" means an 80% chance of finding an effect that size, with a 5% chance of a false alarm. There may not be 100 markets to draw from. I count only about 100 counties of the right size in each kind of work, in states with at least two, before any opt out.
- **What's plausible.** The only guide is the union evidence above: men's pay about 6% higher where unions were about 15 points more common, or about 0.4% for each point. Women's gain was about $900 a year against men's $3,200, and most home care workers are women. It came from comparing places, not from an experiment, and a co-op's pull may not grow point by point the way this assumes. At a 10% co-op share, the men's figure suggests an effect of about 4%, and the women's about 1%.
- **So the rule says no, for now.** Both of those are below 5%. The filed rule compares the smallest detectable effect with the union figure, weighted by how many men and women do the chosen work. On public numbers it fails for every kind of work I checked, and fails badly for home care. The test only runs if the independent team, using the detailed records, shows it passes. If it never passes, the local test isn't run, and the page says what that means: the labor-market effect stays untested. A smaller effect, like the union one for women, is probably too small for any affordable local test to see.
- **What counts as "reached."** If the test does run, help markets must average at least 10% of the hours in the chosen work done by workers at new co-ops, measured in year five by the independent team from wage records. That's ambitious. The pilot's own help is judged against about 32 extra co-ops from about 250 founding groups ([Help Before the First Loan](before-launch.md#the-cost-ceiling)), roughly one in eight. At that rate, 10% of a 2,000-job market, about seven co-ops of 30, would take something like 55 founding groups in one place. So the test also runs only if the pilot's results make that reachable.
- **A checkpoint, so the money isn't wasted.** If help markets average under 4% at the end of year three, the help stops and the result is "not tested." The same team audits whether the help was actually delivered. If it wasn't, the result says so, and it's reported as a broken promise, not as co-ops failing. Pay in the markets is still tracked for the full five years.
- **What it costs.** In a 2,000-job market, seven extra co-ops at the pre-launch help's own limit of $400,000 each cost about $2.8 million, or $280 a job a year over five years. Hence $300 a job. With 50 help markets averaging 3,000 jobs, that's about $225 million over five years. None of it is lending. Loans still go through the bank's normal rules.
- **One main measure.** The main result is filed in advance: the pay of workers who stay in their jobs at employers other than co-ops, in one kind of work. Everything else, including other kinds of work, groups of workers, new hires' pay and the share of workers below a low-pay line, is reported with a correction for testing many things at once. Employers that set pay across many markets, like home care chains, are reported separately, since they could carry any response into the markets without help.
**This test decides nothing by itself.** It isn't part of the pass-or-fail rule, which stays about whether co-ops can compete. Its results are published for voters and Congress to weigh. If co-ops never reach the filed share in the chosen markets, the result is "not tested." If they do and conventional employers don't change, the result is "no effect," and it's reported that way.
## Compared with wage boards and unions
The labor-market effect is about raising pay for workers who never join a co-op. Other policies go at that directly. Here's how they compare, on the best evidence I could find. The comparison isn't like for like, and I say where.
**Wage boards.** A wage board sets minimum pay for one kind of work, usually with workers and employers at the table. Two recent ones:
- **California fast food.** Since April 2024, restaurants in fast-food chains with more than 60 locations nationally, franchised ones included, have had to pay at least $20 an hour ([California Labor Code 1474](https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=LAB&sectionNum=1474.)). California's fast-food and similar restaurants had about 569,000 jobs in 2023 at an average of about $26,900 a year, and about 554,000 jobs at about $30,300 in 2025 ([BLS county and state pay records](https://www.bls.gov/cew/downloadable-data-files.htm)). Not all of them are at covered chains, and those raw changes also reflect everything else going on in California.
- **Pay.** The studies agree pay went up because of the law. Compared with similar places, about 7% on average in one careful estimate ([Dube, 2026](https://www.nber.org/papers/w35171)), and 8% to 9% for covered workers in a Berkeley report ([Reich, 2025](https://irle.berkeley.edu/publications/brief/effects-of-the-20-california-fast-food-minimum-wage/)).
- **Jobs.** They disagree. One team finds about 3% fewer fast-food jobs than there would have been, around 18,000 ([Clemens, Edwards and Meer, 2025](https://www.nber.org/papers/w34033)). Dube's main estimate works out to roughly 1% fewer, and his other methods range from small losses to small gains. He also finds workers quit much less often. The Berkeley team, using phone location data at each restaurant, finds no job loss ([Reich and Sosinskiy, 2026](https://irle.berkeley.edu/publications/press-release/most-comprehensive-study-yet-finds-no-job-losses-from-californias-20-fast-food-minimum-wage/)). Another team using restaurant-level data finds losses concentrated at small chains, which also opened fewer new locations and closed more ([Melo, Neumark, Sigaud and Wagner, 2026](https://www.nber.org/papers/w35749)).
- **Prices.** Prices for all eating out in four California cities rose about 3.3% to 3.6% more than elsewhere by the end of 2024 ([Clemens and others, 2026](https://www.nber.org/papers/w34990)). The Berkeley report, looking at fast-food prices, finds about 1.5%, or six cents on a four-dollar hamburger. They measure different things.
- **Minnesota nursing homes.** Minnesota's nursing home board set minimums of $19 to $27 an hour depending on the job. They took effect on September 10, 2026 ([Minnesota Department of Labor and Industry](https://www.dli.mn.gov/nhwsb)). Medicaid pays for much of nursing home care, so the raise needed money from the legislature and federal approval of the state's Medicaid plan. That pushed the start back from January 2026 ([the board's 2025 report](https://www.dli.mn.gov/sites/default/files/pdf/nhwsb_annual_report_2025.pdf)). I couldn't find the dollar cost.
**Unions.** The federal agency that protects the right to organize, the National Labor Relations Board, cost $294 million in 2026 ([NLRB budget request](https://www.nlrb.gov/sites/default/files/attachments/pages/node-155/performance-budget-justification-2027.pdf)), about $2 a year per private-sector job. Where states bargain with home care workers, the raises come out of Medicaid, like Minnesota's. The union evidence above is the main reason to think one employer's pull on others' pay exists at all.
**What each costs, roughly.** These aren't the same kind of cost, so here they are side by side rather than netted:
| | Wage board (California) | Co-op local test |
| --- | --- | --- |
| Who pays | Customers every year, including low-income ones, plus owners' margins and possibly some lost jobs and openings, mostly at small chains | Taxpayers, once |
| How much | About $1,900 a year per covered job (7% of $26,900), for as long as the floor lasts | About $1,500 per job in the market ($300 a year for five years), none of it lent back |
| What it buys | A raise within about two years | A test, which may never run, of whether co-ops at a tenth of the work lift others' pay. On the union evidence, perhaps 4% for men and 1% for women, later, if at all ([The numbers, in a first draft](#the-numbers-in-a-first-draft)) |
If a co-op effect like that did happen and lasted, a one-time $1,500 would be a good buy. But it depends on co-ops reaching a share of the work that this page already doubts they can reach. So as a way to raise pay across a whole kind of work, a wage board is faster, more direct, and cheaper for the government's budget, though its effect on jobs is still disputed. The labor-market effect isn't a reason to prefer co-ops for that job.
**What co-ops do that these don't.** A wage floor raises pay. It doesn't give workers a vote or a share of the profits, it doesn't let them keep what they build, and it lasts only as long as the budget and the politics behind it. Minnesota's slipped for funding. Co-ops give ownership and a vote, but they can't pay more than the work earns ([Ownership and the Floor](the-floor.md)). That's why the model needs both. The case for the bank doesn't rest on the labor-market effect. It rests on whether co-ops can compete and repay ([The Test](the-test.md)). Where they touch:
- **Co-ops don't compete by paying under the floor.** Co-ops pay any floor that covers them, like any other employer, and neither the bank nor the federation seeks exemptions for them. A co-op outside a floor's reach, like an independent restaurant under California's law, still publishes its pay next to the local floor.
- **A floor can hurt thin-margin co-ops too.** A board that covers every employer, as Minnesota's does, raises a small co-op's costs like anyone else's, and the California evidence suggests small firms feel that most. Where the work is publicly paid, a floor without higher rates squeezes everyone. The bank's loan reviews test whether a borrower could survive a plausible rise in the floor for its work. The pilot reports failures that follow a new floor separately, while still counting them.
- **In the local test, a floor or contract is recorded, not thrown out.** The draw is within states, so a statewide floor lands on both halves alike. Local floors and new union contracts are different: they could come from co-ops' pull, which is the effect itself, or they could be timed to spoil the test. So every market drawn stays in the main result. New local floors and major contracts, defined by a pre-filed threshold for how much of the work they cover, are recorded as outcomes, including whether co-op members helped win them. A version without those markets is only a filed side check.
## What this doesn't solve
- **Nationally, it's slow.** Even a clear pass likely means decades before co-ops are common enough to change most workers' options. Nothing here speeds that up. Other policies, like sector-wide wage boards and support for unions, raise pay faster and cost the public less ([Compared with wage boards and unions](#compared-with-wage-boards-and-unions)). This model doesn't replace them.
- **The effect is unproven.** The closest evidence cuts both ways: big retailers paying more didn't lift others' pay, and hospitals hiring nurses did. Co-ops might differ from both, because they offer ownership and a vote, not just pay. That's what the local test is for.
- **The local test may never run.** On public numbers, even 100 markets could only see an effect of about 5% of pay, bigger than the union evidence suggests co-ops could produce at a tenth of the work. A smaller effect, like the union one for women, would go unseen. Co-ops may never reach the filed share anywhere, and there may not be 100 markets of the right size whose local governments opt in. If the test can't pass its own rule, the honest result is that the labor-market effect stays untested. And the union evidence behind the scale estimate comes from comparing places, not from an experiment.
- **Concentrating help will be called unfair.** Conventional employers in the chosen markets will say the government is helping their competitors. The honest answer is that lending terms don't change, the help is time-limited and published, local governments opt in, and it's the only way to learn whether the effect is real. It won't stop the attack.
- **Growth can outrun judgment.** Audited demand, capped stages and published results are the defenses. None of them is airtight.
- **Success builds a lobby.** If the sector grows, its members will defend the bank, as the electric co-ops defended theirs. That's why growth depends on the published results and a vote each time, not on how many friends the bank has.
@@ -0,0 +1,18 @@
---
title: Startups
section: Growing the Sector
order: 90
summary: Fund the science, back the boring, make the co-op a real option for founders, and don't let government pick the startups.
---
# Startups
**One line: fund the science, backstop the boring, give founders a real co-op option. Don't pick the startups.**
Government should not be a venture capitalist. VC works by being wrong 70% of the time and letting two freak winners pay for everything. No public agency survives that headline rate. It would get cautious, fund safe bets and well-connected people, and safe bets don't produce breakthroughs. The failure mode isn't corruption; it's risk-aversion under political accountability.
So:
1. **Fund the science, not the companies.** DARPA and NIH gave us the internet, GPS, touchscreens, mRNA. The state buys the ingredients; a thousand private actors race to cook.
2. **Make the co-op a real option for founders.** A garage company is founders and early employees, with almost no separation between labor and ownership. The bank, internal capital accounts, and the federation exist so that choosing co-op form isn't choosing to be underfunded. Early-stage co-op capital will still cost more than conventional VC. That's [the founder problem](founder-problem.md), and [the test](the-test.md) will show how much it matters.
3. **Backstop the boring.** Rural grocery store, local clinic, unglamorous manufacturer. That's development banking, not venture, and government is genuinely good at it. Loan guarantees, SBA-style. These are also the sectors where co-ops have historically done best.
+234
View File
@@ -0,0 +1,234 @@
---
title: How a Locked Co-op Is Taxed
section: The Co-op
order: 25
summary: Federal tax law already lets worker co-ops pay tax on members' profit shares only once, but only if profit is shared by hours worked, so this page fits the model's rules to that law, says what it costs, and lists what the IRS or Congress still needs to confirm.
---
# How a Locked Co-op Is Taxed
This model relies on an old part of the federal tax code, called Subchapter T, that lets co-ops pass profit to their members and have it taxed only once. Get the details wrong and a co-op pays tax twice on the same profit, which would sink the test for reasons that have nothing to do with whether co-ops work. This page works through those details. It describes federal law as it stands in 2026. It isn't tax advice, and each co-op's bylaws need a tax lawyer's review.
## How co-ops are taxed today
An ordinary corporation pays 21% federal tax on its profit ([26 U.S.C. § 11](https://www.law.cornell.edu/uscode/text/26/11)). When it pays what's left to shareholders as dividends, they pay tax again.
A corporation run as a co-op can skip the first tax on the profit it pays back to the people it does business with, called **patronage dividends** ([§ 1382](https://www.law.cornell.edu/uscode/text/26/1382)). They pay income tax on them instead ([§ 1385](https://www.law.cornell.edu/uscode/text/26/1385)). To count, a patronage dividend has to be ([§ 1388](https://www.law.cornell.edu/uscode/text/26/1388)):
- **Paid according to how much business each person did with the co-op.** In a worker co-op, the "business" is work, and most US worker co-ops divide profit by hours worked ([Democracy at Work Institute guide](https://institute.coop/sites/default/files/resources/Internal-Capital-Accounts.pdf)). The Tax Court accepted a plywood co-op's split by hours in 1965 (*Puget Sound Plywood v. Commissioner*, 44 T.C. 305), though that case was decided under the law before Subchapter T. Today's practice rests on the IRS accepting it since.
- **Owed under rules set before the profit was made,** like bylaws adopted in advance.
- **Not taken from other people's share.** A patronage dividend can't include money from the business of people who got nothing, or got less for the same thing ([§ 1388(a)](https://www.law.cornell.edu/uscode/text/26/1388)). The Treasury's example: if a co-op pays nothing to its non-member customers, the part of members' dividends that came from business with them isn't a patronage dividend ([26 C.F.R. § 1.1388-1(a)(2)](https://www.ecfr.gov/current/title-26/chapter-I/subchapter-A/part-1/subject-group-ECFRc609157f1a908c0/section-1.1388-1)). That part is taxed twice.
Money kept in members' [capital accounts](capital-accounts.md) counts too, if at least 20% of each dividend is paid in cash and the member has agreed to be taxed on the rest now. A bylaw can make membership count as agreement, but only if each member gets a written notice and a copy of the bylaw ([§ 1388(c)](https://www.law.cornell.edu/uscode/text/26/1388)). With automatic membership ([When People Don't Stay Long](high-turnover.md)), that notice is handed over, and signed for, when someone joins. Everything has to be paid or credited within eight and a half months after the year ends.
**The reserve is taxed.** Profit the co-op keeps for itself isn't paid to anyone, so the co-op pays the 21% on it. US worker co-ops that keep a permanent reserve already work this way ([Democracy at Work Institute guide](https://institute.coop/sites/default/files/resources/Internal-Capital-Accounts.pdf)).
**In the standard example** from [Member Capital Accounts](capital-accounts.md#where-each-years-profit-goes), a co-op makes $600,000. The $360,000 paid to members is taxed once, on their own returns. The $180,000 for the reserve is taxed at 21%, about $37,800, so about $142,000 is actually saved. If the $60,000 paid to the federation fund can't be deducted, the tax is about $50,400. Whether it can depends on how the federation is set up (see below).
## Why a locked co-op is a corporation
Many small businesses are partnerships, or LLCs taxed as partnerships, which pay no business-level tax. That doesn't work here, for two reasons:
- **Partners can't be employees.** The IRS treats partners as self-employed, not as employees of their own partnership ([IRS, Publication 541](https://www.irs.gov/publications/p541); [IRS practice unit](https://www.irs.gov/pub/fatca/int_practice_units/self-employment-tax-partners.pdf)). [The pilot](pilot-spec.md#who-can-borrow) requires members to be on the payroll, so they have minimum wage and workers' compensation, and unemployment insurance in most states ([Owners Are Still Workers](labor-law.md)).
- **Partners are taxed on all the profit, including what's kept.** Members would owe tax each year on their share of a reserve they can never take out.
So a locked co-op is a corporation, or an LLC that chooses to be taxed as one ([26 C.F.R. § 301.7701-3](https://www.law.cornell.edu/cfr/text/26/301.7701-3)), and files the co-op version of the corporate tax return.
## Does a locked co-op count as a co-op?
Subchapter T applies only to a corporation "operating on a cooperative basis" ([§ 1381](https://www.law.cornell.edu/uscode/text/26/1381)). Courts and the IRS read that as three things: members control it democratically, capital doesn't control it or take its profits, and profit goes back to the people who did business with it, according to how much they did. If a co-op fails this test, it loses Subchapter T entirely, not just one dividend.
Two features of this model could strain the test:
- **The lock-holder.** An outside organization holds a share that can veto a sale ([The Lock](the-lock.md)). But that share carries no profit and no say in running the business. It only blocks the co-op from being sold.
- **The reserve goes to the federation, not the members, if the co-op closes.** Some US worker co-ops already write this into their bylaws, sending leftover assets to groups that support co-ops ([Democracy at Work Institute guide](https://institute.coop/sites/default/files/resources/Internal-Capital-Accounts.pdf)). But no ruling covers it for this model's co-ops.
This is the first question for the IRS, and for Congress (see [Getting certainty](#getting-certainty)).
## The model's rules, fitted to the law
### What counts as an hour
Profit shares follow hours, so hours have to be defined in advance, in the bylaws:
- **Hours paid**: hours worked, as wage law counts them, plus paid leave and state paid family leave.
- **Salaried staff count 40 hours a week.** Overtime counts once, not time and a half. No one counts more than 2,080 hours a year.
- **Only hours on the payroll records** that match the co-op's quarterly payroll tax filings. Time on committees and the board counts only up to a small share of a person's hours. The co-op's auditor checks a sample of timesheets each year.
Unpaid leave, even protected leave, isn't paid hours. Crediting hours nobody worked could mean paying one person out of another's work, which the rule above forbids. So a co-op that wants leave to count has to offer paid leave. Time on leave still counts toward the ramp, as [Worker Ownership](worker-ownership.md) promises.
### Wages first
Before any profit is shared, every worker is paid at least the going rate for their job for their hours ([Investing for the Long Run](the-long-run.md#the-rules)). If the year's wages fell short, the shortfall is paid first, as wages.
Why: members are employees. If a co-op paid itself low wages and large profit shares, the IRS could say the profit shares are really wages in disguise, and owe payroll tax (Social Security and Medicare) and penalties. That's the same argument the IRS uses against small-business owners who pay themselves too little salary. Paying the going rate first makes the profit share clearly profit. It also helps low-paid members: wages count toward the Earned Income Tax Credit and Social Security, and profit shares don't.
### One rate per hour, for everyone who works
The members' profit pool is divided by hours worked. Before the ramp, every hour earns the same amount, and "everyone's share is identical" means identical **per hour**. A part-timer who got the same check as a full-timer would be collecting from the full-timer's work.
**Non-members get a share too.** The tax law's word is "patron," not "member," so people on probation, and people who chose not to join, can receive patronage dividends. In this model they get the same share per hour as a member starting the ramp, all in cash. The rest of what their hours earned goes to the reserve, on top of the 30% minimum. The reserve still benefits the co-op, which members govern, but no member's own check comes from a non-member's work. Hiring non-members can't raise members' profit shares.
### The tenure ramp
The founding plan has a new member start at a partial profit share and reach a full one over five to ten years ([Worker Ownership](worker-ownership.md)). As first written, the part a new member didn't get went to the other members. The tax law doesn't allow that: a new member and a ten-year member who work the same hours are doing the same business with the co-op, so the ten-year member can't be paid out of the new member's work without that part being taxed twice.
**So the ramp's unpaid part goes to the reserve.** A member at half a share gets half the per-hour amount. The other half goes to the reserve, on top of the 30% minimum.
**What that costs, in the standard example.** Say the 40 members are 32 at a full share and 8 new members at half a share, all working the same hours. As first written, full members got $10,000 and new members $5,000. Now the $360,000 pool is $9,000 for each member's hours. Full members get $9,000. New members get $4,500, and the other $4,500 of each, $36,000 in all, goes to the reserve, which gets $216,000 instead of $180,000. If new members work fewer hours than the others, the full members lose less. In a co-op where few members are new, they lose little.
That changes what the ramp does. The founding plan's reason for it was to stop every new hire from cutting into everyone else's share. Now it doesn't do that: a new member's hours count in dividing the pool from the start. What the ramp still does is make new members earn their way to a full share, with the difference building up the co-op's own reserve instead of senior members' checks.
**Two other ways, both with costs.** A co-op can choose either in its bylaws, before the year starts:
- **Pay the ramp as a wage for years of service.** Everyone gets the per-hour profit share at the starting rate. Senior members get the rest as a pay supplement for years of service, which employers can pay and deduct. That keeps the founding plan's shield for senior members, but it costs payroll tax, about 15% split between the co-op and the member, on most of the profit share. And wages can't be held in capital accounts, so the accounts, and the money the co-op can reinvest from them, shrink.
- **Ask the IRS to accept the old result.** The unpaid part could count toward the reserve's 30% minimum, which lets the co-op put that much more into the members' pool, so senior members get what they did before. But then senior members gain exactly what new members give up, and the IRS could fairly say it's the old formula by another route. It's only an option if the IRS rules that it's allowed.
### The founding share
[The Founder Problem](founder-problem.md#the-tools) sets aside 10% of the members' pool for the first ten years for the people who worked there in the first three years. Paid as a profit share, it would likely fail the patronage test. The rules exclude amounts paid out of other patrons' earnings to people doing "substantially identical transactions" ([Treas. Reg. § 1.1388-1(a)(2)(ii)](https://www.law.cornell.edu/cfr/text/26/1.1388-1)), and a founder and a later hire working the same hours in the same year are doing much the same business with the co-op. Some worker co-ops measure patronage by wages instead of hours, which could change that. This model uses hours ([One rate per hour](#one-rate-per-hour-for-everyone-who-works)).
**So it's paid as pay,** in the year it's earned, and only to people still working there that year.
- **Deferred-pay rules.** Pay that vests by working the year and is paid soon after that year ends is generally not "deferred" under the rules for deferred pay ([26 C.F.R. § 1.409A-1(b)(4)](https://www.law.cornell.edu/cfr/text/26/1.409A-1)). Paying it only to people still there, each year, is meant to fit that. The standard bylaws still need a tax lawyer's review.
- **Reasonable pay.** It's pay set as a share of profit, and the IRS can treat unreasonable pay to owners as a disguised dividend ([26 U.S.C. § 162(a)(1)](https://www.law.cornell.edu/uscode/text/26/162)). The cap, no more than the going-rate pay for the person's job each year, keeps a founder's total at most about double the going rate. The first pilot co-ops include it in their ruling request (below).
- **Overtime.** For hourly workers it's a bonus they're owed, so it counts in their overtime rate for the year it's paid.
- **It costs payroll tax,** paid by the co-op and the person, and it's paid in cash, since wages can't be held in capital accounts.
### Capital accounts
- **The usual way.** Members agree to be taxed now on what's credited to their accounts, and at least 20% is paid in cash. The model's default is half in cash, which covers the tax for most members.
- **Members with lower incomes can choose not to be taxed until they're paid.** The law allows a second kind of credit, a "nonqualified" notice. The co-op pays the 21% now and gets it back as a deduction when the account is paid out, and the member pays tax only when they receive the money ([§ 1382(b)(2)](https://www.law.cornell.edu/uscode/text/26/1382)). That will be the default for members under twice the poverty line who take the standard split. It waits for the Labor Department's answer on pension law, because an untaxed credit a member chooses in advance looks most like the deferred-pay plans courts have called pension plans ([Member Capital Accounts](capital-accounts.md#why-not-pay-it-out-when-you-leave)). Until then, those members take their whole share in cash by default, or the standard split, taxed now. These credits follow extra timing rules, [below](#credits-taxed-later-the-deferred-pay-rules).
- **Charges come out of this year's share first.** Money members already paid tax on can be moved back to the reserve, under [the pay rule](the-long-run.md#the-rules) or when losses are charged to accounts. That's probably taxable to the co-op a second time, and the member may be able to deduct only part of it. So those charges come out of the current year's profit share first, before it's credited or taxed, and reach older balances only when that isn't enough.
### Interest on accounts
Accounts earn a return by formula ([Member Capital Accounts](capital-accounts.md#how-the-account-grows)): the one-year Treasury rate plus two points while a member works there, the plain Treasury rate after they leave. How that's taxed depends on whether an account is a debt the co-op owes or an owner's stake in it.
- **A business deducts interest only on a debt** ([26 U.S.C. § 163(a)](https://www.law.cornell.edu/uscode/text/26/163)). The factors the tax code lists for telling a debt from an owner's stake include an unconditional promise to pay a fixed sum on a set date at a fixed rate, and whether the money ranks behind the business's other debts ([§ 385(b)](https://www.law.cornell.edu/uscode/text/26/385)).
- **An account is an owner's stake.** It shares in losses, ranks behind lenders, and its payouts can be paused. So its return is probably a return on capital, not deductible interest, even after the member leaves. Co-ops can deduct dividends on capital only if they're tax-exempt farm co-ops ([§ 1382(c)(1)](https://www.law.cornell.edu/uscode/text/26/1382), [§ 1381(a)](https://www.law.cornell.edu/uscode/text/26/1381)). For everyone else, the Treasury's rule is that the profit split by patronage is what's left *after* dividends on "capital stock or other proprietary capital interests" ([26 C.F.R. § 1.1388-1(a)(1)](https://www.law.cornell.edu/cfr/text/26/1.1388-1)). So the co-op pays its 21% on the return first.
- **The return is paid every year, not just in profit years.** The model used to pay the extra two points only when the co-op made a profit. That made it look like capital taking a share of the profit, which is one of the things the test for being a co-op at all rules out ([above](#does-a-locked-co-op-count-as-a-co-op)). A fixed, capped rate paid in good years and bad is how co-ops have long paid capital. It's credited to the account, not paid in cash, so a loss year doesn't cost the co-op cash.
- **Account holders pay the tax on their own return.** The return is credited after the co-op's 21% tax on it. With the Treasury rate at 4%, a current member's 6% is credited as about 4.7%, and a former member's 4% as about 3.2%. Otherwise members who take all their share in cash, as lower-income members can, would pay part of the tax on other people's savings.
- **The return itself comes off the top.** Because it's paid before the patronage split, the reserve, the federation fund and everyone's per-hour share all carry it. In the standard example, a co-op making $600,000 credits at most $180,000 a year to accounts, so after five years at most about $900,000 is waiting to be paid out. At 6%, that's at most about $54,000 a year, less in practice, since newer members, all-cash members and the insured slice (which earns the plain rate) hold less. A member with no account carries at most about $800 a year of it. That's the price of the capital the whole co-op uses instead of borrowing it, and it's published every year, per hour worked and per dollar in accounts.
- **Members are probably taxed when they're paid, not when it's credited.** The money can't be touched until then. When it's paid, it's probably a dividend. A worker co-op isn't a tax-exempt corporation, so it may count as a "qualified" dividend, taxed at the lower rates for investment income, which are zero for many lower-paid members ([§ 1(h)(11)(B)](https://www.law.cornell.edu/uscode/text/26/1)). So the second tax is often small. But crediting some members' accounts while others take cash could be treated as a payment when it's credited ([§ 305(b)(2), (c)](https://www.law.cornell.edu/uscode/text/26/305)). That goes on the list for the IRS ([below](#getting-certainty)).
- **If the IRS says the return breaks the co-op test,** the premium drops to the plain Treasury rate automatically, under the bylaws, for returns not yet credited.
- **Why not make a former member's balance a debt?** It looks like a way to make their interest deductible, but it doesn't hold up. Interest that can be paused isn't "unconditionally payable," so it would be taxed each year before it's paid anyway ([26 C.F.R. § 1.1273-1(c)](https://www.law.cornell.edu/cfr/text/26/1.1273-1); [§ 1272(a)](https://www.law.cornell.edu/uscode/text/26/1272)). A note running several years must pay at least the federal rate for its term, which can be above the one-year rate ([§ 1274(d)](https://www.law.cornell.edu/uscode/text/26/1274)). Interest that can't be paused would pay people who left ahead of those who stayed, which the pause rules forbid. And a balance that turns loss-proof the day someone quits rewards quitting just before a bad year. The deduction isn't worth that.
### Credits taxed later: the deferred-pay rules
A nonqualified credit isn't taxed until it's paid, years after the work. Federal tax law has strict rules for pay that's put off like that ([26 U.S.C. § 409A](https://www.law.cornell.edu/uscode/text/26/409A)). They cover only pay for services, and the model's position is that a profit share is an owner's share, not pay ([Owners Are Still Workers](labor-law.md#is-the-profit-share-pay)). Nobody has decided whether they reach a co-op's patronage credits. So the model follows them as a precaution, and the bylaws say it does so without conceding that they apply. Breaking them would be expensive for exactly the members this option exists to help. Everything put off becomes taxable at once, with interest and an extra 20% tax (§ 409A(a)(1)(B)). So once nonqualified credits are offered:
- **Only co-ops using the federation's software offer them.** The rules below are easy to get wrong by hand. In other co-ops, lower-income members choose between all cash and the standard split, taxed now.
- **Only lower-income members get them, by a choice made before the year starts.** A member chooses between the standard split and all cash by the end of the year before, and the choice holds for the whole year ([26 C.F.R. § 1.409A-2(a)(3)](https://www.law.cornell.edu/cfr/text/26/1.409A-2)). A new member can choose within 30 days of joining, and then it covers only profit from hours worked after the choice (§ 1.409A-2(a)(7)). The same choice fixes how the credit will be paid: at once, or in monthly payments that all fall in one calendar year. Whether a member is under twice the poverty line is judged on the higher of the last two years' household income, from their tax returns, so one low year doesn't qualify. The co-op can't decide after a year ends which credits are nonqualified.
- **The schedule is fixed before the year starts.** A vote to change the wait applies to nonqualified credits only for profit from years that start after the vote.
- **They're paid unless paying would put the co-op at risk.** Deferred pay can be held back past its date only if paying it would threaten the employer's ability to keep going, and then it has to be paid in the first year it wouldn't ([§ 1.409A-3(d)](https://www.law.cornell.edu/cfr/text/26/1.409A-3)). That's narrower than the co-op's ordinary solvency test, which also pauses payouts that would break its loan terms or its minimum cash level. So by December 1 each year, the co-op's outside accountant looks at the last checked accounts and a forecast, and says in writing whether paying the nonqualified credits due would have that effect. If not, they're paid by December 31. The bank's loan terms leave these payments out of its tests, so paying them doesn't put a co-op in default, and the federation pays for the finding.
- **No five-year note.** After a two-year pause, other paused payouts become a debt repaid over five years ([Member Capital Accounts](capital-accounts.md#protecting-the-co-op-and-the-people-who-stay)). Stretching nonqualified credits out that way would count as putting them off, which the rules allow only with a year's notice and at least five more years' wait (§ 1.409A-2(b)(1)). So they stay paused and are paid in full in the first year the co-op can pay them without that risk. That means they can be paid ahead of former members waiting on the note, an exception to the rule that the same year's credits are treated the same. They belong to the lowest-paid members, and are usually small.
- **Small balances follow a rule set in advance.** Paying a whole balance early is allowed if it's under a federal limit well above the $2,000 used here, pays out everything the co-op owes that person under every similar deferred arrangement, and the co-op's decision is in writing by the payment date (§ 1.409A-3(j)(4)(v)). So the plan counts all of a person's nonqualified credits together, and the board records the decision at each payout.
- **If a co-op gets it wrong, it fixes it and pays the cost.** It first uses the IRS's correction program, which cuts or removes the extra tax for mistakes fixed quickly ([IRS Notice 2008-113](https://www.irs.gov/pub/irs-drop/n-08-113.pdf)). Whatever extra tax and interest remain, the co-op pays, grossed up for the tax on that payment. That payment is wages, so it goes through payroll, and it's paid by the end of the year after the member pays the tax (§ 1.409A-3(i)(1)(v)). It doesn't make up for public benefits a member loses when years of income land at once.
### If the co-op fails
Members pay ordinary income tax on account credits in the year they're made. If the co-op fails and the account is lost, the loss is probably a capital loss, which is worth much less. A credit written as stock, or as a registered certificate of what the co-op owes, is a "security" for this purpose, and a worthless security counts as a capital loss ([§ 165(g)](https://www.law.cornell.edu/uscode/text/26/165)). A credit that's neither is probably a personal debt gone bad, also a capital loss ([§ 166(d)](https://www.law.cornell.edu/uscode/text/26/166)). A member could argue the credit was tied to their job, which the law treats as a business debt. But for an employee that's a miscellaneous itemized deduction, and those are suspended for good ([§ 67(h)](https://www.law.cornell.edu/uscode/text/26/67)), so that route is worth nothing. Capital losses beyond any capital gains offset only $3,000 a year of other income, and the rest carries forward ([§ 1211(b)](https://www.law.cornell.edu/uscode/text/26/1211)). Some states are harsher.
So a member who loses $20,000 deducts $3,000 a year for seven years, unless they have capital gains to set it against. For a low-paid member who owes little income tax, the deduction is worth little. They were taxed in full on money they couldn't take out, and get little back when it's lost.
The small-business stock rule, which lets owners take up to $50,000 a year of such a loss against any income ([§ 1244](https://www.law.cornell.edu/uscode/text/26/1244)), probably doesn't help. It covers only stock issued for money or property, and Treasury's rules say stock issued for services doesn't count ([26 C.F.R. § 1.1244(c)-1(d)](https://www.law.cornell.edu/cfr/text/26/1.1244(c)-1)). Credits are figured by hours worked. It also stops applying once a company has taken in $1 million for its stock, which a mid-sized co-op passes in a few years. And it helps most the members who pay the most tax.
**Less exposure first.** The model's main answer is to keep less taxed money at risk:
- **Losses hit this year's share first.** Charges come out of the current year's profit share, which hasn't been taxed yet, before they reach older balances ([above](#capital-accounts)). That part of a loss costs members nothing in tax.
- **Accounts are capped** at one year's pay, and at least a fifth of each year's share, half by default, is paid in cash.
- **The insurance pool** covers former members' insured amounts and a protected part of current accounts ([Protecting Members' Savings](savings-guarantee.md)).
- **Once the Labor Department answers** on pension law, credits taxed only when paid could be offered more widely. A credit that was never taxed can't be lost after tax.
**Then a general fix.** The law the bank needs anyway also gives back the tax a worker actually paid on money they had no right to take out, when their employer's failure wipes it out. It's figured from the worker's own past returns and refundable, so it's worth the same to a low-paid member as a high-paid one. It isn't only for co-ops: any worker taxed on pay their employer held back and lost gets the same. It applies only to failures under a court's supervision. Credits made in the last year before the failure count only if the co-op's outside accountant has already checked that year's books.
**Timing.**
- **The pool comes first.** While a claim on the pool is pending, the loss usually can't be claimed yet. A member claims whatever the pool doesn't cover once it has decided. If the pool pays after a member has already deducted the loss, that payment is taxable to the extent the deduction saved them tax ([§ 111](https://www.law.cornell.edu/uscode/text/26/111)).
- **Members have seven years.** Claims for refunds based on worthless securities or bad debts can be made up to seven years after the return was due ([§ 6511(d)(1)](https://www.law.cornell.edu/uscode/text/26/6511)), because the year a loss happens is often unclear.
- **Members get the facts in writing.** The trustee or the pool sends each person their account balance, what they got back and when, and the key dates in the case. It can't decide the tax year for them. The co-op's [benefits check](#what-the-co-op-and-federation-do) helps them file.
- **Losses charged while the co-op is running** can't be deducted when they're charged. They show up only when the account is finally paid out for less than was taxed, probably as a capital loss.
Credits taxed only when paid were never taxed, so there's no loss to claim on them.
## Payroll tax on profit shares
Wages carry payroll tax, about 15% in all, split between the co-op and the worker. Whether patronage dividends paid to worker-members do too is unsettled. The likelier challenge is that profit divided by hours worked is really wages. A second theory is that it's self-employment income. The IRS has claimed the second against members of at least one worker co-op, then backed down when challenged, without a published explanation ([Gregory Wilson, co-op tax lawyer](http://www.gwilson.com/documents/Article4(PatronageDivTax).pdf)). His argument: a member paid a fair wage through payroll is like a shareholder who works for the company, and a shareholder's dividends don't carry payroll tax. The wages-first rule is built to support that argument, and the model makes the same argument under state wage laws, though not every law sees it the same way ([Owners Are Still Workers](labor-law.md#is-the-profit-share-pay)).
If the IRS wins, members pay about 15% more on their profit shares. Low-paid members would at least earn Social Security credit and Earned Income Tax Credit on it.
## Benefits
Many care and cleaning workers count on public programs, and becoming a member shouldn't cost anyone their benefits. The aim is for benefits agencies to count this money correctly, not to hide it from them. Programs look at two things: savings and income.
### Savings
Some programs have a savings limit. For Supplemental Security Income (SSI), it's $2,000 for one person ([20 CFR 416.1205](https://www.law.cornell.edu/cfr/text/20/416.1205)), and Medicaid for older and disabled people generally uses similar rules. Medicaid for most working-age adults has no savings limit at all: "A State shall not apply any assets or resources test" ([42 U.S.C. 1396a(e)(14)(C)](https://www.law.cornell.edu/uscode/text/42/1396a)).
**The membership share and credits that haven't come due shouldn't count as savings,** because members can't turn them into cash. SSI counts property a person "could convert to cash," and "if a property right cannot be liquidated, the property will not be considered a resource" ([20 CFR 416.1201](https://www.law.cornell.edu/cfr/text/20/416.1201)). Food assistance (SNAP) doesn't count "resources having a cash value which is not accessible to the household" either ([7 CFR 273.8(e)(8)](https://www.law.cornell.edu/cfr/text/7/273.8)). That only holds if members really can't get at the money. So the standard bylaws say that **the share and account credits can't be withdrawn early, sold, given away, pledged or borrowed against**, except that they pass to a named person on death and a court can divide them in a divorce ([Member Capital Accounts](capital-accounts.md#how-its-paid-out)). The co-op gives members a statement of the facts for their caseworker: the balance, the payment dates and that clause.
### Income
Profit shares count as income for Medicaid, marketplace health insurance subsidies, food assistance and housing aid. Four things make this harder than it looks.
**SSI may count a credit when it's made, not when it's paid.** SSI counts income "at the earliest of the following points: when you receive it or when it is credited to your account or set aside for your use" ([20 CFR 416.1123](https://www.law.cornell.edu/cfr/text/20/416.1123); the same for wages in [§ 416.1111](https://www.law.cornell.edu/cfr/text/20/416.1111)). A member under twice the poverty line can already choose to take their whole share in cash, so a credit they leave in the account could be seen as income they set aside. So **members on SSI get their whole share in cash, with nothing credited to an account and nothing held back for the buy-in,** until Social Security says how it counts credits. They're full members all the same.
**SSI may count the share as the less favorable kind of income.** Wages, including "bonuses... received because of your employment," are earned income ([20 CFR 416.1110](https://www.law.cornell.edu/cfr/text/20/416.1110)), and SSI ignores the first $65 a month and half of the rest ([§ 416.1112](https://www.law.cornell.edu/cfr/text/20/416.1112)). Dividends, "returns on capital investments," are unearned income ([§ 416.1121](https://www.law.cornell.edu/cfr/text/20/416.1121)), and SSI ignores much less of it. The model argues the share is an owner's share for tax purposes ([Owners Are Still Workers](labor-law.md#is-the-profit-share-pay)), and Social Security may take it at its word. So every example shown to a member on SSI assumes the worse answer, and the same question matters for other rules for disabled workers, including the ones that let them keep Medicaid as they earn more. Opting out of membership doesn't change this, because workers who aren't members get a cash share too.
**Timing helps some programs and hurts others.** SNAP treats a one-time lump sum as savings, not income ([7 CFR 273.9(c)(8)](https://www.law.cornell.edu/cfr/text/7/273.9)). SSI counts it as income in the month it arrives. So a payout from an account can come as one payment or up to 12 monthly payments, and which is better depends on the member's programs. The federation's benefits advice helps each member choose.
**ABLE accounts protect savings, not income.** A member with an ABLE account, a savings account for people whose disability began young, can have payouts paid into it. Balances up to $100,000 don't count against SSI's savings limit, but the money still counts as income in the month it's paid, and deposits are capped each year ($20,000 in 2026) ([POMS SI 01130.740](https://secure.ssa.gov/poms.nsf/lnx/0501130740)).
### What the co-op and federation do
- **A benefits check before anyone is signed up automatically.** The federation, not the co-op, offers every worker on probation a confidential benefits check in their own language. The co-op doesn't ask anyone about disability or benefits. A worker the check shows is on SSI or disability benefits becomes a member only if they say yes.
- **A statement at every credit and every payment,** so members can report both to their agencies on time. Members are told they must report them.
- **Getting an answer from Social Security.** Social Security doesn't give advance rulings. The federation asks it in writing for guidance on how it counts profit shares and credits, and supports a real member's case through appeal if needed. It shares the answer with every co-op.
- **Benefits advice for every member,** along with the templates and training the federation already supplies ([The Federation](federation.md)).
The model doesn't shrink the benefit cliffs themselves. A low-paid member on several programs at once can lose much of a profit share to lower benefits and higher rent. The advice can show them that before they decide. It can't change it.
## Getting certainty
The IRS gives binding answers on specific facts, called private letter rulings, but only to the taxpayer who asks, only about its own real transactions, and never as precedent for anyone else ([26 U.S.C. § 6110(k)(3)](https://www.law.cornell.edu/uscode/text/26/6110)). A ruling costs a fee and often takes months, and the IRS can decline to give one. So:
- **The first pilot co-ops ask, and the bank pays.** They ask whether the co-op operates on a cooperative basis with the lock and the reserve, whether the per-hour profit shares and the ramp count as patronage dividends, whether the founding share is deductible as pay, whether profit shares carry payroll tax when wages meet the going rate, how charges moved back to the reserve are taxed, whether the return on accounts strains the cooperative test and when members are taxed on it, and whether the deferred-pay rules, or the payroll-tax rule for deferred pay ([26 U.S.C. § 3121(v)(2)](https://www.law.cornell.edu/uscode/text/26/3121)), apply to nonqualified credits at all.
- **Until then, every co-op uses the rules on this page, and gets a tax lawyer's opinion on its bylaws.** None of them depends on a ruling to work.
- **Congress settles it.** The bank's own law needs Congress anyway, and so does [the bankruptcy amendment](bankruptcy.md). That law also says a locked co-op that meets these rules operates on a cooperative basis, and that its profit shares aren't wages for members paid at least the going rate. That part is a statement of how existing law applies, not a lower tax rate, though settling the payroll-tax question is worth real money to members ([How this compares](#how-this-compares-with-other-businesses)). The same law also makes one real change, open to any employer's workers: it refunds the tax they paid on money they lose when their employer fails ([If the co-op fails](#if-the-co-op-fails)).
## How this compares with other businesses
A co-op isn't taxed more lightly than other small businesses across the board, or more heavily. It depends on what it's compared with, and on how the payroll-tax question comes out ([above](#payroll-tax-on-profit-shares)).
**Profit paid to members** is taxed once, at their own rates. So is the profit of partnerships, sole owners and S corporations (small corporations whose owners are taxed on the business's profit as if it were their own). But those owners can usually deduct up to 20% of their business profit first ([26 U.S.C. § 199A](https://www.law.cornell.edu/uscode/text/26/199A)), a deduction the 2025 tax law made permanent. Co-op members can't. So:
- **Against a partnership or a sole owner, the co-op comes out ahead, if its payroll-tax position holds.** Those owners pay self-employment tax, about 15%, on their whole profit. If a member's profit share carries no payroll tax, as the model argues, a $9,000 share avoids about $1,270 of it, against a deduction worth $200 to $400 at the rates most members pay.
- **Against an S corporation, the co-op is somewhat behind.** An S corporation owner who works in the business takes a reasonable salary, which carries payroll tax and gets no deduction, and takes the rest as profit with no payroll tax. That's the closest match to the model's wages-first rule. The difference is the deduction members don't get.
- **If the IRS wins on payroll tax, the co-op is behind everyone.** Members would pay payroll tax on their profit shares and still get no deduction. For low-paid members, the loss is partly made up: a profit share that counts as earnings earns Social Security credit and Earned Income Tax Credit, which in its lowest range adds 34 to 45 cents for each extra dollar earned by a worker with children. For them, winning the payroll-tax argument isn't clearly better.
**Why members can't take the deduction.** It covers profit from a business the taxpayer carries on themselves (§ 199A(c)(1), (d)). Working as someone's employee isn't one (§ 199A(d)(1)(B); [26 C.F.R. § 1.199A-5(d)](https://www.law.cornell.edu/cfr/text/26/1.199A-5)), and owning a share of a corporation isn't either. Patronage dividends count only when the patron does business with the co-op as part of a business of their own, like a farmer selling grain through a marketing co-op ([§ 199A(c)(3)(B)(ii)](https://www.law.cornell.edu/uscode/text/26/199A); [26 C.F.R. § 1.199A-7](https://www.law.cornell.edu/cfr/text/26/1.199A-7)). A worker-member has no business of their own for the dividend to belong to.
That's the same position the model takes on payroll tax: a profit share is an owner's return on a corporation, not pay and not business income. A member can't have it both ways. Claiming the deduction would mean saying the share is business income, which is the IRS's self-employment theory. So:
- **Members don't claim it.** The co-op's year-end patronage forms report nothing for it, and the tax lawyer's opinion on the bylaws covers it.
- **Members join only as individuals on the payroll,** not through companies of their own. Setting up as contractors wouldn't work anyway: the rules presume someone doing the same work for a former employer is still an employee (§ 1.199A-5(d)(3)).
- **The co-op itself can't take it,** because it's a corporation. The only co-ops with a deduction of their own are farm and garden co-ops (§ 199A(g)). The new $400 minimum deduction (§ 199A(i)) doesn't reach members either.
**What it's worth.** In the standard example, members share $360,000 of profit. The deduction on that would be worth roughly $9,000 to $16,000 a year across 40 members. It's close to nothing at the bottom, since it's capped at 20% of taxable income and many low-paid members owe little. In a conventional firm, the same profit would go to one owner, whose deduction would be worth about $23,000 to $25,000 a year, and an S corporation owner can deduct on profit kept in the business too. The gap is mostly federal: most states start from a figure that comes before this deduction.
**The model doesn't ask Congress to extend it.** The deduction is a break for business owners that's worth most to the best-paid. Asking for a share of it would make the pilot a subsidized test. The model does ask Congress to say that profit shares aren't wages for members paid the going rate ([Getting certainty](#getting-certainty)). That isn't nothing: it's worth more than the deduction. It's the same treatment an S corporation owner gets on profit above a reasonable salary, and it's why the going-rate test has to be strict.
**Profit the co-op keeps** is taxed at 21% and never again, because nobody can ever take it out. A conventional corporation's kept profit is taxed at 21% and again when it's paid out. A partnership's or S corporation's owners are taxed on it at their own rates, which can be higher or lower, and they own it.
**The difference that matters:** a conventional owner's kept profit is still theirs. The co-op's reserve belongs to no one, and it still pays tax. That's a cost of the lock, and this model doesn't ask for a tax break to cover it.
## What this doesn't solve
- **The ramp no longer shields senior members.** Under tax law, the founding plan's reason for the ramp works only through the reserve, through payroll tax, or with the IRS's blessing. Which is best is open.
- **Workers without work authorization can't be members.** Being on the payroll requires it. Some immigrant cleaning co-ops today are partnerships partly so members with only a taxpayer number can be owners. This model can't do that without giving up payroll protections, and it leaves those workers out.
- **Unpaid leave costs profit share.** Mostly for women, unless the co-op pays for leave.
- **States differ.** Most states follow the federal treatment of co-ops. But some tax a business's gross receipts, like Ohio and Washington, where the patronage deduction doesn't help. And some states' co-op laws may send leftover assets to members when a co-op closes, which would clash with the lock. Each pilot state needs checking.
- **The federation's form matters.** If the federation fund is a charity, a co-op's payments and a closing co-op's reserve are gifts, and corporations can deduct gifts only up to a limit ([§ 170(b)(2)](https://www.law.cornell.edu/uscode/text/26/170)). If it's a trade association, payments may be deductible as a business expense. The choice isn't made.
- **The return on accounts isn't deductible.** It's most likely a return on capital, so the co-op pays 21% on it, charged to the accounts that earn it, and members may pay tax again when they're paid ([Interest on accounts](#interest-on-accounts)).
- **It's a lot of paperwork.** A co-op tax return, a patronage form for every worker, written notices, consent records, hours tracking, and the ramp. The federation provides standard software and filing help at cost, and the pilot's business help pays for setting it up.
@@ -0,0 +1,24 @@
---
title: Making the Case
section: Making the Case
order: 150
summary: Lead with "nobody is forced," point to the co-op banks that already exist, argue from freedom rather than dollars, and don't overclaim.
---
# Making the Case
**Lead with "nobody is forced."** The model takes nothing from anyone. It charters a bank, asks it to lend at market rates to a kind of firm other banks won't touch, and publishes the results. If co-ops can't compete, the experiment says so and it gets wound down. That is the most market-friendly proposal on the left, and it should be argued that way.
**The second line: "we already did this."** Congress chartered a cooperative bank in 1978. Mondragon built one in the 1950s. Italy has run a co-op development fund on member profits for decades. This is an old, tested idea aimed at a new target. The record, failures included, is in [Has This Been Tried?](precedents.md).
**Germany is now a supporting point, not the lead.** Co-determination puts workers on up to half the supervisory board of large firms, by law, and Germany is still a capitalist market economy. It shows ownership and governance rules are choices societies make, not laws of nature. But it's no longer needed to defend a mandate, because there isn't one.
**Don't lead with "they extract while workers starve."** It's the weakest version of the case, and it invites "the return is compensation for capital and risk," which wins the exchange. The strong version:
> *Bargaining power is lopsided. Workers have no realistic path to a claim on the enterprise they build. Capital markets don't fund firms that workers own. Fix the financing and let people choose.*
**Don't argue it in dollars.** The dollars framing ($150k/head, hungry kids) is exactly what leaves the model vulnerable to *"then just give them cash; a big enough UBI is simpler."* If ownership is only an *instrument* for moving money, a UBI substitutes for it.
It isn't. It's the goal. **People deserve to govern the thing they spend their life inside.** The objection is *domination*, not just poverty. That claim is about human freedom, it survives the UBI, and no amount of cash dissolves it.
**And don't overclaim.** The pilot proves business viability or it doesn't. Don't promise that it will reshape the labor market. Say that's what success at scale would do, and let the data earn the rest.
@@ -0,0 +1,28 @@
---
title: The Bank
section: Growing the Sector
order: 50
summary: A small public bank that lends only to worker co-ops, at market rates, with professional underwriting and published results.
---
# The Bank
**A small public investment bank that lends only to worker cooperatives.**
Why a bank and not a mandate: co-ops are rare, and there are two competing explanations. One says they're rare because capital markets won't fund firms whose owners can't sell equity to outsiders. The other says they're rare because they're genuinely harder to run. A bank that removes the financing barrier is the experiment that tells those apart. If co-ops were starved of capital, they'll grow. If they're hard to run, the portfolio will fail, and we'll have learned that honestly and cheaply.
Mondragon's history supports the first half of the premise. For decades, apart from some government job-creation money, its own bank, Caja Laboral, was its co-ops' only source of capital from outside their own members. But that bank also gave them cheap loans and intensive hands-on help, in a market protected by tariffs, so nobody can say which one mattered (see [Has This Been Tried?](precedents.md)).
**Design:**
- **Charter:** lends only to firms that meet the [co-op definition](worker-ownership.md) and accept [the lock](the-lock.md). The bank can't widen its own charter. Mondragon's bank drifted into ordinary lending after a change in the law, and America's co-op bank drifted toward the safest co-ops once nothing held it to its target (see [Has This Been Tried?](precedents.md)). A charter can't stop Congress from changing it, but the fixed measures and deadline in [The Test](the-test.md) mean a mission change can't happen quietly. Drift inside the charter, toward the safest co-ops, is handled by money set aside for co-ops in lower-paid work and results published by group ([Who the Bank Lends To](who-gets-loans.md)).
- **Priced at market rates.** This is deliberate. The bank's edge must be *willingness* to underwrite a firm structure other lenders refuse, not *cheap money*. If it lends below market, every result is unfalsifiable: success gets dismissed as "free state money," failure gets dismissed as "hobbled by the lock." At market rates, survival and returns mean something.
- **Professional, insulated underwriting.** Loan officers are career bankers hired by the board, not political appointees. The board can overrule them only in writing and in public. Lending decisions are walled off from legislators the way monetary policy is walled off from Congress. A bank that funds whichever co-op has the best-connected sponsor fails the experiment and deserves to. Since 2026 the Supreme Court has made clear that any president can replace a board like this one at will, so the protection lives in what the board can't change, not in who sits on it (see [Making It Last](durability.md)).
- **Self-sustaining by design.** One-time capitalization, then it lives on its loan book. Losses are public and published. A permanent appropriation would turn it into a subsidy line, and subsidy lines get captured.
- **Full transparency.** Portfolio data (default rates, survival, wages, productivity, member retention) published annually, so anyone can check the result, including critics.
The actual numbers (how much money, how many co-ops, loan limits, and how "market rate" is checked) are in [The Pilot, in Numbers](pilot-spec.md). The rules against sham co-ops and political favoritism are in [Guarding the Pilot](pilot-safeguards.md). Loans for owners who choose to sell their company to its workers run on a separate track, described in [When an Owner Wants to Sell to the Workers](buyouts.md).
**Precedent:** this has been tried, in pieces. Congress created a National Consumer Cooperative Bank in 1978, but its law was written for consumer co-ops, and after it was privatized in 1981 it became mainly a lender to housing co-ops. Italy's Coopfond, funded by 3% of co-op profits, has lent and invested in co-ops for three decades and still holds about 91% of what it collected. Italy's Marcora Law puts public money alongside workers' own to fund worker buyouts. The details, including how each went wrong, are in [Has This Been Tried?](precedents.md).
**And a warning:** Uruguay's public fund for worker-run firms, FONDES, lost almost 90% of its loan book within five years, mostly on rescues of already-failed firms with political connections. [The pilot](pilot-spec.md#two-warnings-from-real-life) is designed around that failure.
@@ -0,0 +1,32 @@
---
title: Ownership and the Floor
section: Beyond Co-ops
order: 110
summary: Ownership ends the skim, but a floor ends the hunger. Low-margin work needs both, and the floor can't wait for the co-op sector to grow.
---
# Ownership and the Floor
This is the correction that matters most, and it took the longest to see.
**Equal ownership within firms does not produce equal outcomes across them.**
A worker-owned cleaning company still operates on cleaning-company margins, maybe $8k of profit per head. A worker-owned software firm throws off $150k per head. Both workers are owners. One of them still can't feed their kids.
Profit-per-head is set by technology and demand elasticity, forces ownership rules don't reach. Cleaning productivity has barely moved in forty years: one person, one mop. Ownership of the surplus doesn't change how much surplus *exists*.
So:
- **Ownership ends the skim.** It is the right answer to *extraction*: a large surplus flowing to people who didn't make it.
- **A floor ends the hunger.** The home health aide, the childcare worker, the line cook: their problem was never that private equity was skimming their $150k. Their problem is there is no $150k.
You need both. They do different jobs. And because the model doesn't force anyone to convert, this section matters *more*, not less: the co-op sector will grow slowly, if it grows at all, and the floor doesn't wait for it. **One in six Utah kids goes hungry, and a lot of their parents work in sectors where there is barely any surplus to own.**
The floor, in pieces:
- A strong minimum wage.
- **Targeted subsidy for essential low-margin work, paid to workers, not firms.** Somebody has to clean the nursing home for the poor. If the sector can't pay, the public does, directly, so the buyer can't absorb the subsidy by bidding less. Who decides what counts as essential, and how to keep every low-wage industry from claiming it, is in [Who Decides What's Essential](essential-work.md).
- **Funded automation research aimed at exactly those sectors.** If productivity can't rise on its own, raise it. This is explicitly a *transition* strategy: support the workers until the technology lands. Say that out loud, or you end up permanently subsidizing a job you're simultaneously trying to eliminate, and the subsidized workers start lobbying against the automation. (This is what happened with coal.)
- **A transitional public temp corps.** Companies apply for subsidized labor. The state supplies temps and funds the automation in parallel. Everyone knows going in that the job has a sunset. Voluntary, real wages, open to anyone who wants a bridge.
**The endpoint:** a society where work is not the price of being allowed to eat. Where people who don't work are taken care of, and people who work are people who want *more*: the finer things, a bigger share, the reward for doing what's hard or what nobody else will do.
+150
View File
@@ -0,0 +1,150 @@
---
title: The Lock
section: The Co-op
order: 30
summary: Any co-op funded by the bank stays a co-op forever. It can't be sold to conventional owners or cashed out by its members.
---
# The Lock
**Any firm funded by the bank stays a worker co-op forever. It can never be bought, sold, or converted into anything else.**
The lock is what keeps a successful experiment from being quietly bought back. Without it, the best-performing co-ops are the likeliest takeover targets, and every success becomes an acquisition. The lock exists so that wins accumulate instead of being harvested.
What it covers:
- **No sale to a non-co-op.** A co-op may merge with or be absorbed by another locked co-op. It may never be sold to conventional owners.
- **No demutualization.** Members cannot vote to dissolve the co-op and split the value. (This is the failure mode that ended many US mutual savings banks and UK building societies: a generation of members cashed out what earlier generations built.)
- **No outside voting equity.** Outside money comes in only as voteless, expiring claims (see [Capital, After](capital.md)).
- **Membership requirement.** A locked co-op cannot run on hired non-member labor. Workers become members after a defined probationary period (at most 12 months), automatically unless they opt out. In jobs where most people leave within a year or two, that takes a shorter probation ([When People Don't Stay Long](high-turnover.md)). This blocks the "degeneration" pattern, where a co-op becomes a small ownership club employing a large wage-labor underclass. Mondragon's non-member foreign subsidiaries are the warning case.
- **Indivisible reserves.** A slice of every year's profit goes to a collective reserve no member can ever withdraw. On dissolution, after debts are paid, remaining assets go to the federation fund, not to the members. Italian co-op law already works this way. It removes the incentive to kill the co-op for its assets.
What the lock does *not* do: keep failing firms alive. A locked co-op can still go bankrupt, and its assets can still be sold in liquidation. It just can't be sold as a going concern to anyone but another co-op.
## Why "forever" isn't as strange as it sounds
A permanent lock sounds extreme. But plenty of organizations already live under one, and the law already knows how to handle them.
- **Britain's community interest companies.** Since 2005, UK law has offered a kind of company whose assets are locked to its purpose. Official guidance is blunt: "once a company is a CIC it cannot become an ordinary company" ([UK government guidance](https://www.gov.uk/government/publications/community-interest-companies-how-to-form-a-cic/community-interest-companies-guidance-chapters)). A dedicated government regulator enforces the lock. That hasn't scared people off. There were more than 37,000 of them in 2025, and a record 8,376 were approved that year ([CIC Regulator annual report, 2024–25](https://assets.publishing.service.gov.uk/media/68809f4f28f29c99778a7504/cic-25-01-community-interest-companies-annual-report-2024-2025.pdf)).
- **Companies that can't be sold.** Some well-known firms, including Bosch, Zeiss and the search engine Ecosia, are "steward-owned": set up so that control can't be sold to outsiders. Bosch and Zeiss do it through foundations. Ecosia uses a simpler device: an independent body holds a single "golden share" whose only job is to veto any change that breaks those rules ([Wikipedia](https://en.wikipedia.org/wiki/Steward-ownership)).
- **American nonprofits.** Assets given to a charity stay charitable. When Blue Cross of California moved its business into a for-profit company in the 1990s, state regulators made it hand over more than $3.2 billion to two new health foundations. Its first offer had been $100 million ([Community Catalyst, 2004](https://www.communitycatalyst.org/wp-content/uploads/2022/11/conversion_and_preservation_of_charitable_assets_of_blue_cross_and_blue_shield_plans_mar04.pdf)). Notice what happened there, though: the company *did* change form. What the law protected was the value.
There's one honest difference. The founders of a community interest company, or of Ecosia, chose their lock freely. Here the lock is the condition of a public loan. Founders still choose it, since nobody has to borrow from the bank, but they choose it as part of a deal.
**And the failure the lock prevents is real.** Britain's building societies were member-owned mortgage lenders. From 1989 on, several of the biggest (Abbey National, Halifax, Northern Rock, Bradford & Bingley and others) turned themselves into banks. They won their members' votes by paying out windfalls from reserves that earlier generations had built, and speculators opened small accounts just to collect. Every converted society ended up swallowed by a bigger bank or failed ([Co-operative News, 2014](https://www.thenews.coop/big-bang-demutualisation-building-societies-failed/)). Northern Rock, a building society until 1997, suffered Britain's first bank run in well over a century in 2007 and was nationalized the next year ([Wikipedia](https://en.wikipedia.org/wiki/Northern_Rock)). Those failures had many causes, and mutuals can fail too. But the windfall votes show exactly the incentive the lock removes: current members cashing out what their predecessors built.
## How the lock is made to hold
A lock written only into a co-op's own rules can be voted away by its members. A lock written only into federal law can be repealed by Congress. So it's built in layers, and each one is meant to hold if another fails.
1. **A lock share.** Each locked co-op issues one special share to an independent **lock-holder**. The share pays nothing. It votes on only four things: a sale or merger, a change to the lock clauses, a payout on dissolution, and a sale of a large part of the business (below). On those, it has a veto. It **can't approve** a sale to a conventional buyer, because nobody can. And it **must approve** anything that follows the lock, such as a merger with another locked co-op, so it can't hold good deals hostage. A refusal can be challenged in court. State corporate law already allows restrictions of this kind. Delaware, for example, lets a company require consent before its shares change hands, or bar transfers to designated kinds of buyers, as long as the restriction isn't "manifestly unreasonable" ([Delaware Code, title 8, § 202](https://delcode.delaware.gov/title8/c001/sc06/index.html)). This is the same device Ecosia uses.
2. **The co-op's founding documents.** The lock clauses are written into the articles, and they can be changed only with the lock share's consent. (Articles that simply claim they "can never be amended" may not hold up under state law. A required consent is what actually does the work.)
3. **The law.** The bank's authorizing statute makes the lock a condition of every loan and says it outlasts repayment. It also changes federal bankruptcy law for locked co-ops (see below). Where states are willing, the lock goes into state co-op law too, through a short model law ([The Lock in State Law](lock-state-law.md); see also [The Constitution](constitution.md)).
4. **A backstop that follows the money.** If a court or a later Congress ever breaks the lock and a co-op converts or is sold anyway, the reserves and any sale value above members' own account balances still go to the federation fund, not to the members. Italy works this way ([Has This Been Tried?](precedents.md#italys-co-op-funds-coopfond-and-the-marcora-law)). So even if the lock on the firm's form fails, nobody gets a windfall from breaking it.
**Who holds the lock share.** Each co-op picks its lock-holder from a list of approved ones: its [federation](federation.md), or an independent nonprofit lock trust with its own endowment and a successor named in case it ever shuts down. The rules keep the lock-holder honest:
- **It can't be a buyer, or benefit from a sale, in any deal it reviews.** If the co-op's federation might absorb the co-op, or would receive its assets on dissolution, a different lock-holder reviews that deal.
- **It isn't the only one who can enforce the lock.** Any member, any other locked co-op, the bank and the state attorney general can also go to court.
- **It publishes an annual review** of the co-ops it holds shares in.
- **It checks each co-op at least every two years,** including private talks with a random group of workers, for a fee on a scale the bank approves ([What the Rules Cost a Small Co-op](compliance.md#the-lock-holders-review-every-two-years)).
- **It also guards membership.** Beyond its four votes, it has one more job: it takes workers' decisions to opt out of membership and checks each co-op's membership records, so that the membership requirement means something ([When People Don't Stay Long](high-turnover.md)). It has no say over who is hired, fired or paid what.
**Where the legal risk really is.** Courts are wary of "restraints on alienation": conditions that stop an owner selling property, especially forever ([Cornell Legal Information Institute](https://www.law.cornell.edu/wex/restraint_on_alienation)). For company shares, reasonable restrictions are generally allowed, so that risk is manageable. **The bigger threat is bankruptcy.** Federal bankruptcy law generally overrides contract terms that restrict transferring a bankrupt company's property ([11 U.S.C. § 541(c)(1)](https://www.law.cornell.edu/uscode/text/11/541)). Without a change to that law, a court-run sale could hand a failed co-op's whole business to any bidder, lock or no lock. So the bank's statute has to amend the Bankruptcy Code for locked co-ops. That needs Congress. [When a Locked Co-op Goes Bankrupt](bankruptcy.md) lays out the amendment, modeled on how bankruptcy law already treats charities.
**What about members who never agreed to it?** Nobody is forced to work at a locked co-op. The lock is explained in writing when someone is hired and again when they become a member. The trade is the same as joining a nonprofit: you get a vote and a share of the profits in a firm you didn't found, and you can't sell it.
## When a co-op needs to change hands
The lock stops a co-op being sold to conventional owners. It doesn't freeze it in place. A co-op can still:
- **Merge with or be bought by another locked co-op**, including a much bigger one.
- **Split off part of its business** as a new locked co-op, or sell that part to one.
- **Sell things it no longer needs**, like old equipment, a building or a patent it doesn't use, to anyone at a fair price. The gain goes to the reserve (see [Investing for the Long Run](the-long-run.md#the-rules)).
- **Take outside money without giving up control**, on voteless, expiring terms (see [Capital, After](capital.md)).
- **Get help from its federation**, which can lend to a struggling co-op or help find a co-op buyer (see [The Federation](federation.md)).
## Closing the back doors
A lock on the company is worthless if the business can walk out the side door. These rules are part of the lock itself, so they last as long as it does, not only while a bank loan is outstanding.
- **Selling the business piece by piece counts as selling it.** If a co-op sells, leases or licenses more than a quarter of its assets or its revenue-producing business within any three years, counted together, that's treated as a sale of the business, and the lock share can veto it. The same goes for the brand, the core technology, or the main customer contracts, whatever their size.
- **Buying the assets and hiring the workers counts too.** If a buyer of the co-op's assets hires more than a third of its members within two years, the deal is treated as a sale of the whole business after the fact. The buyer owes the federation fund the difference between what it paid and the value of the business as a going concern. The same goes for anyone who gets the use of what the co-op leased, licensed or financed and runs the same business with a third of its workers or its main customers. Leases and licenses of what a co-op can't run without carry the lock's terms too ([When a Locked Co-op Goes Bankrupt](bankruptcy.md#everything-else), point 13). People are always free to change jobs. This rule makes a buyer pay full price for a whole business, even if it picks the business up in pieces.
- **Subsidiaries are covered.** Any company a locked co-op controls is also locked, and its workers are covered by the membership rule. Otherwise a co-op could grow through an ordinary subsidiary and then sell it. That's the pattern behind Mondragon's non-member foreign subsidiaries. How that works, including when a co-op buys a company, is [below](#when-a-co-op-owns-another-company).
- **No outside control through the back door.** The limits in [Capital, After](capital.md) (outside claims last at most 15 years, rank behind lenders, and carry no vote) apply forever, not just while a bank loan is outstanding. No investor, lender, supplier or licensee can hold a veto over the co-op's budget, hiring or management. No contract can give one outside party an exclusive claim on most of what the co-op produces for longer than five years.
- **Insiders can't drain it through contracts.** Rent, fees and prices paid to insiders are checked for as long as the lock lasts, not just while the bank's loan is outstanding ([below](#deals-with-insiders)).
## Deals with insiders
While a co-op owes the bank, every contract with an insider is checked each year, and overpaying one is a breach of the loan ([The hidden boss](pilot-safeguards.md#the-hidden-boss)). Once the loan is repaid, that lever is gone. A founder who kept the building could simply wait, then raise the rent. The lock is meant to stop the firm's value leaving through the back door, and an inflated contract is a back door. So the check becomes part of the lock.
Members take most of the profit, so they have every reason to refuse an inflated contract themselves, which a charity's donors don't. The danger is narrower: the insider controls what members see, or the co-op can't walk away. So the first defenses are telling members in advance and making sure they can leave a bad deal. Outside checking comes second.
The rules borrow from how federal law polices charity insiders ([26 U.S.C. § 4958](https://www.law.cornell.edu/uscode/text/26/4958)), because the tax law has decades of practice drawing this line. A charity's assets are dedicated to its purpose much as the reserve is dedicated to the people who'll work at the co-op later.
- **Who counts as an insider.** Anyone who in the last five years was a founder, board member, officer or manager, or was otherwise able to steer the co-op; their family; and any company they, together, own more than 35% of. The charity rule treats founding the organization as one sign of influence; this model counts founders and managers automatically, so it's broader. Anyone who signed a contract with the co-op while an insider stays one for that contract and its renewals, however long it runs, so stepping down and waiting five years doesn't work.
- **Through a middleman counts.** If the co-op overpays a supplier who passes part of it on to an insider, it's an insider deal, as it is for charities ([26 C.F.R. § 53.4958-4(a)(2)(iii)](https://www.law.cornell.edu/cfr/text/26/53.4958-4)). So is a swap: if an insider of one co-op deals with a second co-op while an insider of the second deals with the first, both deals count.
- **Members hear about it first.** Every insider deal worth more than $5,000 a year is sent to all members before it's signed, with the price, the terms and the evidence for them, and listed again in the yearly report to members and the lock-holder. Smaller ones are only listed.
- **A fair process earns a presumption.** A deal is presumed fair if it's approved in advance by people with no stake in it, who had evidence of fair value and wrote down their reasons at the time ([26 C.F.R. § 53.4958-6](https://www.law.cornell.edu/cfr/text/26/53.4958-6)). Having no stake means not being the insider or their family, not working under them, not having one's pay set by them, having no money riding on the deal, and not having a deal of one's own that they approve. For pay, prices from three comparable employers nearby are enough in a co-op with sales under $1 million, as the charity rule allows for small organizations. For rent, buildings, equipment and licenses, the charity rule looks to an independent appraisal or real competing offers, and so does this one. Where a deal runs more than $5,000 a year, the appraiser comes from a list the lock-holder keeps, not one the insider picks, and the co-op pays. The accountant then checks the paperwork, not the price. A deal without that approval gets the full price check.
- **When nobody is free to judge.** In a small co-op, everyone may work under the manager who owns the building. If there aren't at least three members with no stake, the deal needs an appraiser from the lock-holder's list, and the lock-holder checks the process.
- **The co-op can always walk away.** Two of the pilot's rules become part of the lock too. Members can end any management or franchise contract by vote on short notice. And a co-op that leases something it needs to operate from an insider has an option to buy it at an appraised price ([The hidden boss](pilot-safeguards.md#the-hidden-boss)). A market price can still be steep, but an insider can't hold the co-op hostage at renewal.
- **Overpaying costs the insider, not the members.** If a deal cost the co-op more than it would have paid a stranger, the insider repays the difference with interest, plus a quarter of it on top. If it isn't repaid within a year, they owe twice the excess on top instead. Charity law charges an insider those amounts as taxes (§ 4958(a)(1), (b)); here they go to the co-op. The insider and their company are both liable, so an empty shell company doesn't end it. The co-op can take what it's owed out of anything it owes the insider under any contract. The reserve's 30% comes out of what's recovered, not the members' profit pool. If nothing can be recovered after a real attempt, the loss is written off and falls on everyone, like any other bad debt. A member who's also an insider is covered by this rule, not by the pay rule, so the same money isn't charged twice.
- **Approvers are liable only if they knew.** Board members who approved a deal knowing it was overpriced owe the co-op a tenth of the excess, up to $20,000 per deal, the share and limit charity law puts on charity managers (§ 4958(a)(2), (d)(2)), and their own payouts stop until it's paid. Following the process above, or relying on written, reasoned advice from a professional on the lock-holder's list, counts as not knowing, as it does for charity managers ([26 C.F.R. § 53.4958-1(d)(4)(iii)](https://www.law.cornell.edu/cfr/text/26/53.4958-1)). The federation's group insurance covers the cost of defending an approver who wasn't found to have known. Most worker-directors earn ordinary wages, and this is meant to deter insiders, not scare them off the board.
- **The members can't wave it through.** Members can pay themselves whatever they decide, and [rule 1](the-long-run.md#the-rules) handles that. What they can't do is hand the co-op's money to someone outside it. An overpayment to an insider who isn't a member is an outside claim on the co-op, and outside claims are allowed only on the terms in [Capital, After](capital.md). An insider's investment in the co-op is an insider deal too: its share of profit has to be priced like a stranger's, and the lock-holder must approve it.
- **Who checks once the loan is repaid.** The yearly insider test stays in the accountant's package. The lock-holder takes over the bank's part: it keeps the list of approved accountants and appraisers, gets the report, picks the samples and orders a check when [a warning sign](compliance.md#audits-by-lot-and-when-something-looks-wrong) appears, such as payments to insiders growing faster than sales, any insider's price rising faster than inflation, any renewal of an insider's lease, or when any member asks. Nobody tied to the insider can take part in the lock-holder's review of that co-op. A co-op with no insider deals over $5,000 a year isn't drawn. Among the rest, one in ten is drawn by lot each year for a check of its insider deals, not a full audit. The check is paid through the fees of co-ops with insider deals, and by an insider found to have overcharged. One in ten is a judgment. It's about what the charity rule's tax gives up: if a quarter on top is the penalty and the chance of being caught is at least one in ten a year, a long contract overcharged year after year is likely to be found before it pays.
- **Who can go to court.** The same parties who can enforce the rest of the lock: the lock-holder, any member, any other locked co-op, the bank and the state attorney general. What's recovered goes to the co-op, and a member who wins has their legal costs paid out of it first.
This is weaker than the check while the loan is out, because the bank's threat to call its loan was the strongest lever there was. An insider who has left and owns nothing the co-op can reach may never pay. A presumption can be gamed with a flattering appraisal. The charity rule has the same weakness, and the IRS can still rebut the presumption with better evidence. Here the lock-holder can too.
## When a co-op owns another company
The membership rule is what stops a co-op from becoming a small club of owners with employees under them. Owning other companies is the easiest way around it. Mondragon's co-ops grew abroad by building and buying ordinary companies. By 2016 they controlled about 100 foreign subsidiaries and joint ventures that weren't worker-owned. Only about half of Mondragon's businesses were co-ops, and only about a third of its employees were members, counting non-co-op businesses at home too ([Kasmir, 2016](https://globaldialogue.isa-sociology.org/articles/the-mondragon-cooperatives-successes-and-challenges)). Its appliance co-op, Fagor, bought the French maker Brandt in 2005, and the workers of its foreign purchases weren't offered ownership. Fagor went bankrupt in 2013 owing €800 million to €1.1 billion, after Spain's housing crash and years of cheaper competition ([Fagor, Wikipedia](https://en.wikipedia.org/wiki/Fagor)). The collapse had several causes. A big purchase adds risk, and more so when the buyer is carrying debt.
So "controlled companies are locked" needs rules for what control is, what happens to the people who come with a purchase, and how a co-op gets out of a bad one.
### What counts as control
- **More than half, or running it.** Holding more than half the votes or the value counts as control. So does the right to name most of the board, or running the company through a management contract. Stakes held by the co-op's managers, members' relatives, or companies they control are added to the co-op's own. Several locked co-ops holding a company together control it together, and the one with the largest stake is responsible for the rules below.
- **Even partnerships.** In a 50–50 joint venture with an ordinary company, the co-op has control only if it runs the venture day to day. Otherwise the stake is an investment. That leaves room for joint ventures, and the hours rule below keeps them from becoming a workaround.
- **Less than control is an investment.** A co-op can hold a smaller stake in an ordinary company as an investment of its reserve, like any investor.
- **Dependence without ownership.** A co-op doesn't have to own a company to run it. It can franchise its business to others, or use a contract manufacturer that works mostly for it. It can't make an independent company convert, and the model doesn't try. Instead, the hours of any franchisee of the co-op, and of any supplier that sells most of its output to the co-op for two years running, directly or through a middleman, count in the co-op's hours test below. Ordinary buying from ordinary suppliers isn't covered, and isn't meant to be.
### What the co-op has to do
- **No lasting subsidiaries with workers.** Within three years of taking control, a controlled company with employees is either merged into the co-op or turned into its own locked co-op run by its own workers, like a [spin-off](growth.md). Its workers choose which, by secret ballot, once they've had the lock explained in writing. If it becomes its own co-op, the parent's stake becomes an investment on the usual terms: repayment plus a share of profit for at most 15 years, with no vote ([Capital, After](capital.md)). Any other shareholders are bought out first at an independent appraisal. A locked co-op run by its own workers meets this rule, though one that sells most of its output to the parent is counted with the parent for the hours test, as spin-offs are. A company with no employees, like one that only holds a building, can stay under the co-op's control. It's locked like any other asset.
- **One hours test across the group.** The co-op itself meets the three-quarters rule as usual. Across the co-op, the companies it controls, and the franchisees and dependent suppliers above, members of locked co-ops have to do at least half of all hours, once the co-op's own phase-in is over. So a co-op can't buy companies one after another, or farm its work out, to keep a large workforce outside membership.
### The people who come with a purchase
- **Membership is offered, not imposed.** Acquired workers become members automatically after probation unless they opt out. Their time at the old company counts toward probation. Nobody acquired can be let go at the end of probation except for cause.
- **Saying no is safe.** Someone who was working there on the day of the purchase and opts out keeps their job and pay, and their hours don't count against any hours test for as long as they stay. The co-op gains nothing by pushing them out.
- **Unions and contracts carry over.** A buyer that keeps most of a unionized workforce generally has to recognize and bargain with its union ([NLRB v. Burns, 1972](https://www.law.cornell.edu/supremecourt/text/406/272)). The co-op recognizes the union and honors its contract. After that, recognition sticks unless most of the workers the union covers vote to end it, the rule the model already uses for recognized unions ([Labor Law](labor-law.md#union-rights-the-biggest-gap)). The company's pension promises stay with it. Where labor law requires something different, labor law wins.
### Buying, and getting out
- **Big purchases need the members.** Buying a company with more workers than the co-op has members, or paying more than a quarter of the reserve, needs a majority of all members, not just those voting, by secret ballot. The members can vote in advance on a ceiling for the price, so the terms don't have to be made public during the deal. The price includes any debt the co-op takes on, and purchases within three years are added together. The federation gives a written opinion first. It's advice, not a veto.
- **Selling one back.** Any sale of a controlled company with employees goes through the lock-holder, whatever its size. The company's workers get the first chance to buy it as their own locked co-op. Within three years of the purchase, before it's merged or converted, the co-op can sell it to anyone else at an independent appraisal. Anything above what the co-op paid goes to the federation fund, so buying companies to run without members and then resell them doesn't pay.
- **Abroad, the same rules, with a substitute.** Where local law has no form for a worker co-op with a lock, the lock-holder can approve a local structure that gives the workers the same control and the same limits on sale, like voting shares held in trust for them. If there's no such structure, the co-op can't keep control past three years. It can hold a smaller stake, or sell as above. That makes plants abroad much harder to run, and it costs co-ops some foreign business.
## When a co-op goes bankrupt
Argentina changed its bankruptcy law in 2011 so that when a firm fails, its workers can form a co-op, keep the business running, and buy it, counting what they're owed toward the price ([Law 26.684, articles 189, 203 bis and 205](https://www.argentina.gob.ar/normativa/nacional/ley-26684-183856/texto)). This model borrows the idea, adapted to American law. The full rules, and the amendment to federal bankruptcy law they need, are in [When a Locked Co-op Goes Bankrupt](bankruptcy.md). In short:
- **Only co-ops can buy the business whole.** Its own workers (as a new locked co-op), the federation, or any other locked co-op whose members do most of its work can bid. It's sold by open bidding among them. The minimum price is what the assets would bring sold off separately, set by an appraiser the court appoints.
- **Workers can keep it running while it's sold,** and count the unpaid wages they choose to hand over toward their bid, up to the amounts bankruptcy law already puts first. The legal minimum wage and overtime aren't handed over. The savings insurance pool pays them in cash first, up to eight weeks.
- **No buying back your own failure cheaply.** People who managed the co-op in the two years before it failed can't get bank financing to buy it back.
- **If no co-op buys it**, the assets are sold off separately to anyone. But no single buyer and its related companies may assemble enough of the pieces to add up to the business.
- **A reorganization can't hand over control.** If a locked co-op reorganizes in bankruptcy instead of closing, the plan can't give voting shares to anyone but members. Creditors who vote against the plan still get what the business would be worth on the open market. That value is tested by inviting outside offers, not just appraised.
- **The buyer answers for the old co-op's products.** A co-op that buys the business takes on injuries from what the old co-op made, its warranty repairs and customers' deposits. So does a co-op whose members keep it through a reorganization.
**This does cost creditors something.** When only co-ops can bid on the whole business, the price may be lower than an open auction would fetch, and lenders will charge for that risk. Creditors who never chose to lend, like people the co-op injured, are paid the difference from the federation fund. That's part of the lock's price (below).
## What the lock costs
The lock has real costs, and they should be stated plainly.
- **Fewer buyers means lower prices, and sometimes a breakup.** When a failing co-op finds no co-op buyer, a business that a conventional buyer would have kept running may be sold off in pieces, and its workers lose their jobs. Lenders get back less, and they'll charge for it. The pilot publishes the bank's recovery rate on failed loans (see [The Pilot, in Numbers](pilot-spec.md#what-gets-measured)), so this cost shows up in the numbers.
- **While the sector is small, so is the pool of co-op buyers.**
- **Owning other companies is harder.** Joint ventures, purchases and plants abroad come with rules ordinary firms don't face ([above](#when-a-co-op-owns-another-company)). Deals are slower, some sellers and partners will walk away, and co-ops may lose business abroad to firms that can own plants there.
- **Members can't take the firm's value with them.** That's the point of the lock, and it's also the heart of [the founder problem](founder-problem.md).
- **"Forever" is a long time.** Critics on the right argue that a permanent lock, bought with one loan, binds generations who never chose it. Italy takes a softer approach: a co-op can become an ordinary company, but its accumulated reserves go to the co-op movement's fund, not to the members. Whether this model should follow Italy is an open question (see [Open Problems](open-problems.md)).
@@ -0,0 +1,153 @@
---
title: Investing for the Long Run
section: The Co-op
order: 27
summary: Why economists expected worker co-ops to underinvest, what the evidence actually shows, and the rules that keep members who are about to leave from starving the firm, running it down or wasting its reserve.
---
# Investing for the Long Run
Picture a machinist who is 60 and plans to retire in three years. Her co-op is voting on a $2 million production line that will take four years to start paying off, then earn money for fifteen. Whichever way it's paid for, part of the cost comes out of the profits she shares in while she's still there, and almost all the benefit goes to people who work there after she's gone. Why would she vote yes?
That's the **horizon problem**: a member's stake in the firm lasts only as long as their job, but many good investments last much longer. In a conventional company the problem mostly doesn't arise. A shareholder who plans to sell next year still wants the company to invest well, because the share price reflects the future profits the investment will earn. A co-op member has no share to sell. [The lock](the-lock.md) makes sure of that.
This page explains why economists worried about this, what actually happens in real co-ops, and how this model handles it.
## The worry
In 1970 the economists Eirik Furubotn and Svetozar Pejovich, studying worker-run firms in Yugoslavia, argued that such firms would pay out too much and invest too little, because members can't take the value of an investment with them when they leave. The same year Jaroslav Vanek, one of the leading academic supporters of worker management, predicted underinvestment in firms that finance themselves from collective savings (both summarized in [Novkovic, *Journal of Socio-Economics*, 2008](https://www.theholmteam.ca/Novkovic-coopDifference2008.pdf)). Taken to its extreme, the prediction is that such firms slowly starve themselves of capital.
It's a serious argument, and the design has to answer it rather than hope it away.
## What actually happens
**The predicted underinvestment mostly doesn't show up in the data.**
- **France.** The largest comparison so far used two big, representative samples of French firms across many industries, including services. Worker co-ops were as productive as conventional firms or more so, used their equipment and labor efficiently, and weren't inefficiently small. Contrary to the textbook prediction, they grew as fast as conventional firms or faster ([Fakhfakh, Pérotin and Gago, *ILR Review*, 2012](https://ideas.repec.org/a/sae/ilrrev/v65y2012i4p847-879.html)).
- **Italy.** A study of a large panel of Italian worker and social co-ops tested the prediction directly. It found the opposite of what the theory expects: the more of a co-op's capital was held in collective reserves that no member can withdraw, the *more* productive it was ([George, Fontanari and Tortia, 2019](https://ideas.repec.org/p/trn/utwprg/2019-20.html)).
**But there's a catch, and it matters.** These co-ops don't get to choose freely. Italian law requires every co-op to put at least 30% of each year's profit into a reserve that members can never take out ([Italian Civil Code, art. 2545-quater](https://www.brocardi.it/codice-civile/libro-quinto/titolo-vi/capo-i/sezione-iv/art2545quater.html); [Lomuscio and Tortia, 2023](https://pdfs.semanticscholar.org/a7eb/90b586cb2adb63c321f1ca616afefb27eb70.pdf)). Mondragon did the same thing by its own rules. So the honest reading isn't "the horizon problem is a myth." It's "**a required reserve seems to be enough to keep it in check.**"
Mondragon's history shows the pressure is real. When its co-ops set their own reserve levels, the share of profit going into reserves swung from 35–53% in the 1960s down to 11–29% in the 1970s. One co-op, Alecoop, which employs students who leave every three years, faced losing its capital as each group took its money out, so it raised its reserve share to 60%. In 1989 Mondragon raised the minimum reserve for all its co-ops from 20% to 50% of profit ([Morris, *The Mondragón System*, 1992](https://www.ilsr.org/wp-content/uploads/2013/03/mondragon-3.pdf)). Where the horizon pressure bit, the answer was a bigger required reserve.
## The fix that backfired: selling your place
There is an obvious way to solve the horizon problem completely: let members sell their membership when they leave. Then, like a shareholder, a retiring member benefits from every good investment, because it raises the price of their place.
The plywood co-ops of the Pacific Northwest did exactly this. Each member owned one share, and shares could be sold to new members. For decades they were among the most durable worker-owned firms in American manufacturing. Economists who later studied the share prices found membership had been extremely profitable ([Craig and Pencavel, *American Economic Review*, 1992](https://ideas.repec.org/a/aea/aecrev/v82y1992i5p1083-105.html)).
Then success worked against them. Workers nearing retirement found their shares had risen from $1,000–2,000 to $25,000, $50,000 or more. Young workers couldn't afford to buy in, so retiring members sold to conventional lumber companies instead ([Lindenfeld and Wynn, 1995](https://geo.coop/articles/why-some-worker-co-ops-succeed-while-others-fail)). Observers called it "the failure of success." It wasn't the only cause of their decline. The whole plywood industry moved south from the 1960s on, and many conventional mills in the Northwest closed too ([Pencavel, lecture notes on worker co-ops, 2018](https://cleo.rutgers.edu/wp-content/uploads/2020/03/John-Pencavel-Notes-for-Students_WorkerCoops2018A.pdf)). But the co-ops that were sold while still profitable were sold because their members could sell them.
The retiring plywood workers weren't doing anything wrong. They were making a free choice with property they legally owned. This model simply doesn't create that property. A tradable membership turns the co-op back into something owned and sold, which is exactly the claim [the thesis](thesis.md) says this kind of firm exists to prevent. And it prices the next generation of workers out, which is how the plywood mills ended up being sold.
## Who pays for an investment, and when
The horizon problem bites when the members voting today pay for an investment whose benefits arrive after they leave. How much it bites depends on where the money comes from.
**1. The reserve.** At least 30% of every year's profit goes into the co-op's permanent reserve (see [Member Capital Accounts](capital-accounts.md#where-each-years-profit-goes)). No current member can ever take that money home, so paying for the line this way doesn't cut our machinist's share this year. (The line's cost still counts against profit a little each year, as point 3 explains.) The reserve is money that already belongs to the co-op's future, and it gets spent on the co-op's future.
**2. Members' accounts.** Money members leave in their [capital accounts](capital-accounts.md) isn't lost when they leave. It's paid back to them with interest. So if the co-op keeps more of this year's profit in members' accounts to pay for the new line, the machinist gets her money back, by default five years later, whether or not she's still there. Her real cost is the wait, and the risk that the co-op fails. Five years is shorter than most machines last, so for long-lived investments this helps less than it would if accounts were held until members left. Federal pension law is why they aren't ([Member Capital Accounts](capital-accounts.md#why-not-pay-it-out-when-you-leave)).
**3. Spreading the cost over time.** A business doesn't count the whole price of a machine as a cost in the year it's bought. It spreads the cost over the machine's working life. A $2 million line that lasts 20 years counts as $100,000 of cost a year. This is called *depreciation*, and it's how ordinary accounting already works. The profit split is based on profit after depreciation, so the machinist isn't charged for the years after she leaves. The bank does the same with repayment: loans for buildings and long-lived equipment are paid back over the asset's working life, up to 25 years for real estate, the same limit the SBA's main loan program uses ([SBA, types of 7(a) loans](https://www.sba.gov/partners/lenders/7a-loan-program/types-7a-loans)).
**This doesn't fix everything.** Depreciation spreads the cost evenly, but the payoff isn't even. If the line takes four years to start earning, the machinist is charged for three years of it and sees none of the benefit. That's the residual horizon problem, and it's why the reserve matters so much. The investments with the slowest payoff are the ones the reserve should pay for.
## Where the gaps are
That leaves four ways the horizon problem can still get in.
- **Paying it out as salaries.** The reserve rule applies to *profit*, and members set their own salaries. A co-op could raise everyone's pay until there's no profit left, and the reserve would get nothing. Newer members have an extra reason to like this: their profit share starts small and grows with tenure, but their salary is paid in full from day one.
- **Pay that isn't salary.** Pensions promised for the future, retiree health cover, generous severance. These push costs onto whoever works there later.
- **Selling off the furniture.** The lock stops anyone selling the *firm*, but not its assets. Members about to leave could sell the building, book the gain as profit, split it, and leave their successors paying rent.
- **Development that pays off late.** Designing a new product or building software costs money now and pays later, but it's usually counted as a cost in the year it's spent. So the members voting today pay for all of it.
## The rules
**1. The reserve's share applies to pay above the going rate, too.** This isn't a cap on wages. Members can pay themselves whatever they decide. But the reserve takes its 30% of everything members receive above the going rate, whether it's called profit or pay.
- **The going rate for each job** is the higher of two numbers: the median pay for that occupation in the co-op's own metro area, from the government's wage survey (the Bureau of Labor Statistics' [Occupational Employment and Wage Statistics](https://www.bls.gov/oes/)), or the local living wage for a single adult working full time, from [MIT's Living Wage Calculator](https://livingwage.mit.edu/). Both are measured per hour, so part-time work and consultants are compared fairly. The living-wage floor matters. In home care the median wage can be close to poverty pay, and a co-op that raises its aides to a living wage shouldn't owe anything for doing it.
- **Why a single adult's living wage.** In Salt Lake City in 2026, MIT puts the living wage at $24.64 an hour for a single adult and $41.55 for one adult supporting a child. An earlier version of this rule let pay up to one and a half times the second figure, about $130,000 a year, count as "raising the bottom." That would have let a co-op pay every cleaner six figures without the reserve getting anything. The single-adult figure, about $51,000 a year there, is still well above what most aides earn. Paying more than that is allowed. The reserve just takes its 30% of the difference.
- **It's checked job by job.** Paying one group below the going rate doesn't make room to pay another group above it. An outside reviewer, the co-op's auditor or the bank's, assigns each person the occupation code for the work they spend most of their hours on, not their title, and the codes are shown to members next to each job. Where the survey has no figure for the metro area, the state figure is used, then the national one.
- **People who work from home somewhere else.** More than one worker in five teleworked in March 2026 ([BLS](https://www.bls.gov/opub/ted/2026/22-6-percent-of-workers-teleworked-in-march-2026.htm)). The wage survey generally counts remote workers at the employer's reporting location, not at their home ([BLS survey instructions](https://www.bls.gov/respondents/oes/faqs.htm)). So the going rate for them still starts from the co-op's own area. What changes:
- **The floor can only go up.** Everyone is paid at least the co-op's figure. For someone who lives elsewhere, the living-wage part of the floor is the higher of the co-op's living wage and the one where they live. The minimum wage and wage laws of the state they work in apply too, including higher salary thresholds for overtime. A move that raises the floor counts from the day of the move.
- **The line for high pay can rise only for jobs hired as remote.** Suppose a co-op filled a job with a posting that advertised it as remote, and the person lives in a metro area where the survey's median for that job is higher than the co-op's. Then that person's pay counts as "above the going rate" only above their own metro's figure. So a co-op in a cheap area can pay what it takes to hire a remote engineer in an expensive city without the reserve taking 30% of the difference. A remote worker in a cheaper place gets no raised line.
- **Never for insiders.** Board members, officers and managers never get the raised line, and neither does anyone who switches to remote after being hired. "Go remote" can't be used to move insiders' pay out of the reserve's reach.
- **Fully remote means fully remote.** No more than 10 days a year at a co-op site, logged. Anyone who comes in more than that is held to the co-op's figures. The raised line is counted month by month, so it ends in the month someone stops qualifying.
- **It's checked, not claimed.** The auditor checks the home address against a lease or state and local tax records, and checks the site logs. The co-op also reports its share of pay above the going rate without the raised line, so any use of it shows.
- **The floor is what counts for tax.** The raised line only decides what the reserve takes. It isn't a claim about what pay is reasonable. The going rate that [the tax argument](taxes.md#wages-first) relies on is the floor.
- **Contractors and workers abroad.** Pay to contractors doing a regular job for the co-op, and to staffing or employer-of-record firms supplying workers, counts as pay under this rule, both for the floor and for the line. Workers abroad are held to the co-op's own figure as their floor, with no raised line, and the auditor checks their contracts and payment records. That largely rules out hiring abroad to pay less for the same job. It's a choice: the going rate exists to stop pay being cut because of who does the work, and where they live is part of that. It costs co-ops some hires, and a co-op in an expensive city will hire fewer remote workers in cheaper places for the same reason.
- **It counts total pay.** Employer retirement contributions above 6% of pay, bonuses, severance beyond two weeks' pay per year worked (up to 26 weeks), and perks like a car, housing or loans to members, valued at cost, all count as pay. So does pay to non-members and to people working through a related company, and the value of any deal with a member or their family on better than market terms, like buying from a relative's firm above the market price. Co-ops can't promise pensions or retiree benefits they don't pay for in full each year. The severance line is there to stop severance from becoming a payout to departing insiders, so it only helps people let go against their will: laid off, or dismissed for anything short of serious misconduct. If the job is filled again within a year, it wasn't a layoff. Severance on retiring or quitting counts as pay in full. Anything the law requires never counts as pay. The weeks are valued at the person's average pay over their last three years, as New Jersey does, so a raise just before leaving doesn't inflate them. For comparison, New Jersey requires one week per full year in mass layoffs and closings ([P.L.2019, c.423](https://pub.njleg.state.nj.us/Bills/2018/PL19/423_.HTM)), and Ontario requires one week per year, up to 26 weeks, of larger employers once someone has worked there five years ([Ontario](https://www.ontario.ca/document/your-guide-employment-standards-act-0/severance-pay)). This line allows twice the rate per year, with the same 26-week cap. The factor of two is a judgment.
- **How it's settled.** Thirty percent of the excess goes to the reserve, first out of the member's profit share for that year, before it's paid or credited, and then out of their [capital account](capital-accounts.md). No new cash has to be found, because the members already took that money as pay. Taking it from this year's share first avoids a second round of tax on money already credited ([How a Locked Co-op Is Taxed](taxes.md#capital-accounts)). If that isn't enough, the rest comes out of their future account credits, or out of their payout when they leave. For pay to non-members, it comes out of that year's members' profit pool.
- **There's a precedent.** US tax law already treats salaries that aren't "reasonable" as disguised profit ([26 U.S.C. § 162(a)(1)](https://www.law.cornell.edu/uscode/text/26/162)), so owners can't dodge tax by calling profit pay. This rule stops the same disguise being used to dodge the reserve. The tax test is judged case by case. This one is mostly a fixed formula, so it's cruder but much harder to argue about. The one judgment call is who counts as fully remote, and the auditor checks that against records.
**2. Gains from selling assets go to the reserve.** If a co-op sells a building, equipment, a brand or a patent for more than its value on the books, the whole gain goes to the reserve, not into the profit split. A sale to an insider, or a sale-and-lease-back, is checked like any [insider deal](pilot-safeguards.md#the-hidden-boss).
**3. Development projects can be spread out, like machines.** A co-op can designate a specific development project *before* it starts spending on it: designing a new product or a better production process, or building software. For the profit split only, its cost is then spread over up to five years, the same way a machine's is.
- **Only real development qualifies.** It has to meet the test international accounting standards already use to decide when development creates a real asset: it's technically doable, the co-op intends to finish it and can, and it's likely to earn money ([IAS 38](https://www.ifrs.org/issued-standards/list-of-standards/ias-38-intangible-assets/)). Those standards specifically exclude training, advertising and start-up costs, and so does this rule. Those stay costs of the year they're spent.
- **The people who chose it carry the risk.** If a project is abandoned or written down within five years, the cost that hasn't yet been charged comes out of the capital accounts of the people who were members when it was designated, including former members whose payouts haven't been made yet. Each credit waits three to seven years before it's paid, so much of the money is usually still there. That makes the rule useless for pushing costs onto the next group of workers.
- **Limits.** The total cost still waiting to be charged can't exceed half of the co-op's average profit over the last three years, measured before any spreading. Only co-ops with an independent audit can use the rule, and the auditor checks each open project every year. The lighter review small co-ops get otherwise doesn't count ([What the Rules Cost a Small Co-op](compliance.md#what-a-review-can-and-cant-stand-in-for)).
- **It only changes how costs are divided between this year's members and future ones.** It doesn't touch the co-op's tax return or its reports to the bank. The co-op publishes both numbers side by side.
**4. Accounts earn a fair rate while they're at risk.** A current member's account earns the one-year Treasury rate plus two percentage points, credited every year, good or bad, so it's a fixed return on capital and not a share of profit ([How a Locked Co-op Is Taxed](taxes.md#interest-on-accounts)). Money in a current member's account is at risk: it shares in the co-op's losses and ranks behind its lenders. Paying only a risk-free rate on it would push every member to take cash instead. **Once a member leaves, the balance earns the plain Treasury rate.** It no longer shares in losses, so it's no longer at risk, and nobody should collect a premium from the work of people they no longer work alongside. For comparison, Italian law lets co-ops pay members up to 2.5 points above the rate on its postal savings bonds on the capital they've put in (Lomuscio and Tortia, 2023), and Mondragon paid 6% plus an adjustment for inflation (Morris, 1992). The details are on the [capital accounts page](capital-accounts.md#how-the-account-grows).
**5. The reserve doesn't pay for high pay in a loss year.** Mondragon calls its members' pay *anticipos*, advances on profits ([Morris, 1992](https://www.ilsr.org/wp-content/uploads/2013/03/mondragon-3.pdf)). This model treats the part of pay above the going rate the same way: as an advance against profit that hasn't been earned yet.
- **If the year ends in a loss,** the part of the loss up to the pay above the going rate, in that year and the year before, is charged to the people who got that pay, in proportion to how much each got. It comes out of their [capital accounts](capital-accounts.md), and out of their payouts if they've left since. The 30% rule 1 already moved from their accounts counts toward it, so nobody pays twice. The rest of the loss is shared as usual.
- **An example.** A co-op loses $50,000. Its managers were paid $30,000 above the going rate that year, and rule 1 already moved $9,000 of that into the reserve. The managers' accounts are charged $21,000 more. The other $20,000 of the loss is shared between the reserve and all members' accounts in the usual way.
- **What their accounts can't cover** comes out of their future profit shares, above the cash they need for tax, and nobody's above-going-rate pay can rise until it's repaid. After three years, anything still owed is written off. Three years is a judgment. Much shorter, and the best-paid could wait out a charge. Much longer, and one bad year would hang over people's savings for most of their time at the co-op. Whatever is written off falls on the reserve, which means on the people who work there later.
- **Pay above the going rate to non-members** is charged to all members in proportion to their profit shares, since members chose to pay it. Members whose household income is under twice the poverty line are left out, and so is the insured slice of anyone's account.
- **It never touches pay already earned.** Wages and bonuses already paid or earned stay paid. The charge falls on savings in the co-op, not paychecks.
Without this rule, members could vote themselves high pay, run the co-op at a loss, and let the losses eat the reserve once their own accounts ran dry. That would be withdrawing the reserve by another name, which [the lock](the-lock.md) forbids. The cost is real too. In an honest bad year, the best-paid people lose some savings, and some may leave.
## When members stop investing, or invest badly
The horizon problem has two mirror images. Members who are leaving soon might let the co-op run down. And because the reserve can never be paid out, members might spend it on expansion that doesn't pay.
### Running it down
Picture the worst case: a co-op whose members are mostly near retirement. What could they gain by letting it run down?
- **Pay can't drain the reserve** (rules 1 and 5), **and neither can selling off assets** (rule 2).
- **Closing doesn't pay.** If the co-op dissolves, members get their own account balances, which they'd get anyway, and everything else goes to the [federation fund](federation.md) ([The Lock](the-lock.md)). Nobody gets a windfall from shutting a working firm.
- **The co-op can stop hiring and shrink as people leave.** But workers it does hire join after probation, and members must do at least three-quarters of the hours ([When People Don't Stay Long](high-turnover.md)). It can't replace its members with cheaper employees.
That still leaves two real ways to run it down, and both come from how accounting works.
- **Skipped upkeep.** Repairs count as a cost in the year they're done, so skipping them raises this year's profit, and members share it.
- **Machines that are "paid for."** Accounting spreads a machine's cost over its expected life, its *depreciation*. Once that time is up, the machine stops counting as a cost, even if it's still running. If nobody replaces it, profit looks higher every year, and members split the difference. That's not savings. It's the machine wearing out.
**6. Worn-out equipment keeps being charged.** For the profit split only, buildings and equipment that are fully written off but still in use keep being charged at their last yearly rate, adjusted for inflation. That money goes to the reserve, not the split. So a co-op running on old machines builds up the money to replace them, instead of paying it out. Co-ops whose buildings and equipment still in use, at what they originally cost and including leased ones, come to less than a fifth of their yearly revenue, averaged over three years, can skip this. Revenue here leaves out materials and subcontracted work passed on to customers at cost. Measuring at original cost means letting equipment run down can't bring a co-op under the line, and counting leased equipment means selling it and leasing it back can't either. For a rough sense of who that leaves out: on 2022 corporate balance sheets, buildings and equipment at original cost came to about 14% of sales in professional services, 8% for building contractors and 22% for specialty trades like plumbing, 22% in the group that includes home health care, 27% in cleaning and other building and office services, 37% at restaurants and 41% in manufacturing ([IRS, Table 1](https://www.irs.gov/statistics/soi-tax-stats-corporation-complete-report)). Those totals are weighted toward big companies, and the smallest ones don't report balance sheets, so they're a guide, not a measurement. A home care co-op that owns a few laptops is far under the line. The charge only falls on equipment that's fully written off anyway, so for a co-op near the line little turns on which side it's on.
There's no fair way to set how much every business should spend on upkeep, so this model doesn't try. Instead there's a warning light:
- **Every year, the co-op shows members how much it spent on new buildings and equipment**, including new leases and any unpaid invoices it has sold, next to how much its existing ones wore out that year, adjusted for inflation.
- **If spending stays below wear for three years running, an independent inspector reports** on the condition of the main buildings and equipment. The lock-holder picks the inspector and the co-op pays. The report goes to every member, and to the bank if it's still owed money. After a buyout, buying the business puts its used equipment on the co-op's books at today's value, spread over the few years it has left, which makes it look like it's wearing out fast. So for the first five years, wear is measured on the seller's old book values instead. The upkeep the seller put off, which the [pre-sale check](buyouts.md#a-fair-price) already counts, is the starting point: spending that doesn't catch up on it counts as falling behind. A conversion without a sale doesn't re-value anything, so nothing changes for it.
- **A co-op that's shrinking on purpose,** because its market is, says so in a written plan its members vote on. That's a warning light, not a spending order.
### Spending it badly
Critics worry that money members can never take home will feel free, and get wasted on expansion. Fagor, Mondragon's appliance maker, expanded across Europe and beyond, with plants in Poland, France, Morocco and China, much of it on borrowed money. Then Spain's housing crash took away its main market, and it went bankrupt in 2013.
The danger is real, but reserve money isn't free to members.
- **It earns money when it's not spent.** Reserve money that isn't used earns interest, and members share in that interest. Spending it on a project that earns less makes their share smaller. They only get about 60% of that interest, so it's cheaper to them than it should be. It isn't free.
- **Failures come partly out of members' own accounts.** When a project fails, its value on the books is cut, and that counts as a loss. Losses are shared between the reserve and members' accounts in the same proportions as profit ([Member Capital Accounts](capital-accounts.md#how-the-account-grows)). The federation's share doesn't take losses, so members pay about 60 of every 90 dollars, two thirds. That holds only while they're still there, and only up to what their accounts hold, which is capped at a year's pay.
**Fagor wasn't mainly a reserve problem.** It owed about €800 million (about $1.1 billion) when it filed for bankruptcy ([The Local, 2013](https://www.thelocal.es/20131114/major-manufacturer-fagor-files-for-bankruptcy)). A study based on 44 interviews found that Fagor kept units losing millions of euros a year open well before the 2008 crisis. Its elected board often couldn't follow the business plans managers brought it, so it approved them and fired the managers when they failed ([Basterretxea, Cornforth and Heras-Saizarbitoria, 2022](https://journals.sagepub.com/doi/abs/10.1177/0143831X19899474), summarized by the authors in [GEO](https://geo.coop/articles/failure-cooperative-governance)). The Mondragon group had already put €70 million into Fagor from its shared fund, and other co-ops had lent more. In October 2013, when Fagor asked for €170 million more, the group's council refused unanimously, saying the money "would not ensure its business future" ([GEO, 2013](https://geo.coop/story/major-mondragon-co-operative-faltering)).
The risks that remain are the ones Fagor shows: big bets that members can't judge, losses that show up after the people who voted have left, and help from other co-ops that keeps a failing business going. So:
- **A second opinion on big commitments.** Some commitments need an independent written opinion before members vote: a single investment, a purchase of another business, or borrowing, including leases, guarantees and selling unpaid invoices, bigger than a quarter of the co-op's own capital (its reserve plus members' accounts) or $250,000, whichever is larger. Anything bigger than the co-op's whole capital needs one too, if it's over $50,000. So does buying any business for more than $350,000, the price above which the SBA requires an independent valuation before it backs a loan to buy a business ([SBA SOP 50 10 8.1](https://legacy.sba.gov/sites/default/files/2026-09/SOP%2050%2010%208.1%20Technical%20Policy%20Updates%20effective%2010.1.2026.docx)). The SBA rule protects a lender's price, not members' judgment, but it marks where a purchase is big enough to need an outside look. Related commitments within three years are added together, and the dollar figures rise with inflation. An outside opinion can easily cost thousands of dollars, so for commitments under $250,000 the federation's staff can give it for a flat fee, and the federation's fund pays that fee for co-ops with less than $250,000 of their own capital. The lock-holder picks the adviser from an approved list, and the co-op pays. A lender to the project can't give the opinion. The opinion comes with a one-page plain summary, including what the plan means for members' jobs, and there's a members' meeting to ask questions before the vote. It's advice, not a veto. The co-op publishes it with the result of the vote, and three years later reports how the project did against the plan. Replacing worn-out equipment like for like, and loans the bank has already checked, don't need it, except for buying a business. [Spin-offs](growth.md) get a similar opinion at a lower threshold.
- **Losing parts of the business are visible.** Where a co-op has separate lines of business, each one reports its own results to members every year ([Does Sharing the Profit Mean Less Effort?](free-riding.md#small-teams-open-books)). A line losing money for years can't hide inside the total.
- **A board that can follow the plans.** The federation offers training in reading the accounts, on paid time and open to every member, not just the board. The standard bylaws let members elect outside directors, from other co-ops or with business experience, but never more than a quarter of the board. The members choose them and can remove them.
- **Limits on propping up a firm, decided in advance.** The [federation's fund](federation.md) helps a co-op through a bad year. Help to keep a firm going comes with a recovery plan and a limit set before any crisis, and cuts to members' pay or jobs in that plan need the members' vote. Help for the *workers*, like finding them jobs in other co-ops if the firm closes, has no such limit. Mondragon kept supporting Fagor and then said no. It's better to agree on where the line is before anyone's firm is on the wrong side of it.
## What's still open
- **Upkeep.** Nothing forces a co-op to maintain what it has. The warning light shows when upkeep falls behind, but members can still ignore it.
- **Late failures.** A big project that fails after the members who chose it have left is paid for by the people who came after. The second opinion and the three-year report make bad bets less likely and more visible. They don't move the cost back to the people who made them.
- **Deals with insiders after the loan.** The check on deals with insiders now outlasts the bank's loan as part of the lock ([Deals with insiders](the-lock.md#deals-with-insiders)). It's weaker once the loan is repaid, since the bank can no longer call it.
- **Whether 30% is enough.** Italy requires 30%. Mondragon found 20% too low and moved to 50%. This model uses 30% as a minimum that each co-op can raise. [The pilot](pilot-spec.md#what-gets-measured) measures investment per worker, and the return it earns, against matched conventional firms. So if co-ops invest too little, or waste what they invest, it will show.
- **Is the account rate right?** Two points above Treasury is well below what investors expect from a small business, so members may still prefer cash. Paying the co-op's own return on its assets instead would reward good investment directly, but it would also make the account more volatile. And lower-income members, who can take their whole share as cash, don't get the two points at all. That's fair only if the two points are a fair price for the risk the account carries, not a bonus. Which is better is unsettled.
@@ -0,0 +1,18 @@
---
title: The Test
section: Growing the Sector
order: 60
summary: The bank runs as an experiment: metrics set in advance, a fixed review date, and a scale-up or wind-down rule nobody can change after seeing the data.
---
# The Test
The bank is an experiment, so it runs like one.
- **Pre-register the metrics before the first loan:** survival rates, productivity, wages, member retention, and loan performance, measured against matched conventional firms in the same sectors and regions.
- **Fixed review point.** After a set period (long enough to see business cycles), an independent review publishes the results. The pilot sets it at year 12.
- **Scale-up or wind-down, decided in advance.** If co-ops match or beat conventional firms at market-rate lending, the bank's capital is expanded. If they don't, it's wound down and the loan book is sold. Both outcomes are written into the charter from day one, so nobody can move the goalposts after seeing the data. That includes me.
[The Pilot, in Numbers](pilot-spec.md) spells all of this out: the exact measures, the comparison group, the pass and fail thresholds, and an emergency stop.
**What the test can prove, and what it can't.** At pilot scale, this tests *business viability*: can co-ops compete, survive, and repay market-rate debt? It does **not** test the labor-market effect, meaning the idea that as co-ops grow, conventional firms have to treat workers better or lose them. That effect is real in principle, but it needs a co-op sector big enough to move wages at the margin. Mondragon employs tens of thousands of people and hasn't reshaped Spanish employment practice. The labor-market effect is the long-run payoff of success, not something the pilot can claim. [After a Pass: How Big, How Fast](scale.md) works through how big the sector would need to get, and how the effect could be tested in local markets once it grows. On public numbers, that local test could only see a large effect, so it may never run.
+18
View File
@@ -0,0 +1,18 @@
---
title: The Thesis
section: Foundations
order: 10
summary: Personal property is untouchable. Productive capital, a permanent claim on other people's work, is a different thing, and there should be firms where that claim never forms.
---
# The Thesis
**The word "property" protects two very different things, and only one of them deserves that protection.**
There is *personal property*: your house, your car, your savings, your tools. The things you live in and use. These are untouchable. Nobody is collectivizing your couch.
Then there is *productive capital*: a perpetual, tradeable claim on the output of a thousand people you will never meet. This is not the same kind of right. It has been wearing the same word by accident, and the accident does political work for the people who benefit from it.
The dividing line: **it stops being a tool and becomes capital when it earns you a claim on other people's labor that you don't work alongside.** The plumber's van is not extracting from anyone. TPG's stake in Conservice is. (What that company would look like as a co-op is worked through in [What If Conservice Had Been a Co-op?](conservice.md).)
What follows from the thesis is not "therefore the state may take that capital." It is something narrower and harder to argue with: **therefore there should be firms where that claim never forms in the first place, and nothing in law or finance should stop people from building them.**
@@ -0,0 +1,112 @@
---
title: Co-ops or Their Founders?
section: Growing the Sector
order: 65
summary: If the pilot fails, the first question will be whether co-ops failed or the people who started them had less going for them, so founders on both sides are measured from government records and the verdict has to say how much that explains.
---
# Co-ops or Their Founders?
[The Founder Problem](founder-problem.md) says the lock costs a successful founder most of what they'd get from selling the business. So the strongest founders may go conventional, and the bank would be lending to the ones who were left. If the pilot then fails, nobody will know whether co-ops failed or the people who started them did.
The pilot can't stop that from happening. It can make sure the answer isn't a matter of opinion. This page is how.
## Why it's worth measuring
The worry isn't just theory.
- **In equal-sharing organizations, the best-paid people leave most.** In Uruguay, co-op members paid above their co-op's median were about 3.7 times as likely to quit as members paid below it, and the gap was smaller in co-ops that paid more unequally ([Burdín, *Economic Journal*, 2016](https://doi.org/10.1111/ecoj.12272); [free copy](https://gwern.net/doc/economics/2016-burdin.pdf)). In Israel's kibbutzim, which shared income equally, members with more education and more skilled jobs were the most likely to leave during a financial crisis in the late 1980s ([Abramitzky, *Quarterly Journal of Economics*, 2008](https://doi.org/10.1162/qjec.2008.123.3.1111); [free copy](https://ranabr.people.stanford.edu/sites/g/files/sbiybj26066/files/media/file/abramitzky_qje_2008.pdf)). If talented people walk out of equal-sharing firms, some will also never walk in.
- **Founders who believe in it stay.** The same Uruguayan study found founding members were less likely to quit, and the best-paid founders much less so than other best-paid members. That's the kind of founder [the founder page](founder-problem.md#the-honest-bottom-line) is betting on.
- **A founder's background predicts how the business does.** Using Census records that link founders to their past jobs, one study found that founders with three or more years in their start-up's industry were about twice as likely to build one of the very fastest-growing firms (the top 1 in 1,000) as founders with no experience in it ([Azoulay, Jones, Kim and Miranda, NBER working paper 2018, published 2020](https://www.nber.org/papers/w24489)).
- **That background isn't handed out fairly.** A study of why Black-owned businesses did worse than white-owned ones found that a large part of the gap came from Black owners having had less chance to work in a family business, or in a business like the one they started, first ([Fairlie and Robb, 2004](https://docs.iza.org/dp1292.pdf)). So "less experience" often means "fewer chances," not "less able." That shapes how the results are worded below.
It could also go the other way. The bank screens every plan, and co-ops always have at least three founders, so co-op founders could turn out to have *more* going for them than the firms they're compared with. Then a pass would flatter co-ops. The measurement below catches either.
## Who counts as a founder
The same rule on both sides: the co-ops and the matched conventional firms that got SBA loans ([Who co-ops are compared against](pilot-spec.md#who-co-ops-are-compared-against)).
- **A founder is someone who owned part of the business at the start and did real work in it.** For a co-op, that's a founding member named in the loan application. For an SBA firm, it's an owner of 20% or more, since those are the owners the SBA names on the loan. On both sides, they count only if payroll records show them working at least a set number of hours in the first two years. Someone added to the application for their résumé, who never works there, doesn't count, and the report lists how many such people there were.
- **The two sides aren't the same in one way.** SBA owners personally guarantee the loan. Co-op members don't ([The Pilot, in Numbers](pilot-spec.md#loan-terms)). So the founders on the two sides didn't carry the same risk, and the report says so.
- **Team size is handled openly.** Co-ops have three or more founders; most SBA firms have one or two owners at 20%. So the team's average is weighted by hours worked, and the "strongest founder" is compared only between teams of the same size, because the best of five people will usually beat the best of two just by numbers. The report says how many co-ops found matches at each size. SBA firms with nobody at 20% are counted and reported, not quietly dropped. As a second version, the analysis also counts the officers who run each SBA firm.
## What gets measured
Each item comes from government records, so nobody can talk their way up or down.
- **Past pay.** Pay per quarter actually worked, averaged over the best three of the eight years before founding. Using quarters worked and the best years means a parent who took time out for caregiving isn't marked down for the years away. Missing records count as missing, not as zero. It's ranked two ways, and both are reported:
- **against everyone of the same age nationally,** which shows whether the co-ops drew people from the lower-paid end of the whole economy;
- **against people of the same age, state and industry,** which shows whether they drew the lower-paid people *within* their line of work. Co-ops in lower-paid work are part of the design ([Who the Bank Lends To](who-gets-loans.md)), and a home care founder shouldn't count as having less going for them for earning less than an engineer.
- **Experience in the industry.** Years worked in the co-op's industry before founding.
- **Owned a business before.** From tax records of past business income.
- **Age**, as a background fact, not as a score.
The report also shows the co-ops' industry mix against all new firms, since staying out of whole industries is one way strong founders could avoid co-ops without it showing in any one founder's record.
## Getting the data
**The charter sets it up before the first loan.** It directs the bank, the SBA, the IRS and the Census Bureau to set up the linking, with every approval in hand before lending starts. That matters because research on tax records needs IRS approval project by project, and the Census Bureau's researchers can't count on getting it. Past studies show it can be done: Brown and Earle linked SBA loans to Census business records ([*Journal of Finance*, 2017](https://papers.ssrn.com/abstract=2641184)), and Azoulay and his coauthors linked founders to their past jobs.
- **Co-op founders consent when they apply,** on the standard IRS form that lets the reviewers see their tax records. So the co-op side can be done even if the Census linking falls through. The records are linked after the loan decision and never go to the people deciding loans.
- **Comparison founders aren't contacted.** Their records are linked inside the Census Bureau's secure research labs, where names and Social Security numbers are replaced with internal codes. Census law allows the data to be used only for statistics, never against the people in it, including for tax or immigration enforcement. Only totals leave the lab, after the Census Bureau's own privacy review.
- **State wage records are voluntary.** Years of experience come partly from state unemployment-insurance records, and each state decides whether to share them. Coverage is reported state by state, and results are shown using only the states that took part.
- **The cost comes from the pilot's budget**, so it counts against the bank in the money test.
**If the data never arrives,** the verdict says so in one fixed sentence that names which approval was missing and who withheld it. A missing founder analysis never delays or changes the pass or fail.
## How it's used
The analysis is filed in advance, with its code, the same as the rest of [the test](pilot-spec.md#what-gets-measured). It has three steps.
**1. Who came.** The report shows how co-op founders compare with the comparison founders on each measure, with both pay rankings. It shows the same comparison with first-time owners without much wealth set aside, since the bank seeks them out on purpose. And it shows how the founder measures differ by race, gender and whether founders are recent immigrants, so that a gap caused by who gets paid less for the same work is visible as that.
**2. How much of the result do founders explain?** The survival comparison is run a second time, also matching on the founders' measures. It isn't a second pass/fail test. The report gives **how far the result moved**, with its range of uncertainty. If co-ops were 30% more likely to fail in the main comparison and 10% more likely against firms with similar founders, founders explain about two-thirds of the gap. If the range around that change is too wide to tell whether it's zero or the whole gap, the report says "too few to tell," and that's decided by a rule filed in advance, not by the reviewers' judgment.
**3. How big a hidden difference would it take?** Records don't capture everything about a founder. So the report also says how strong a difference the records *can't* see would have to be to change the verdict, using a standard method for this question (the "E-value" of [VanderWeele and Ding, *Annals of Internal Medicine*, 2017](https://doi.org/10.7326/M16-2607)), measured against the pilot's actual pass line, not against "no difference at all." What counts as fragile is fixed in advance: a result is fragile if a hidden difference no stronger than the measured ones already turned out to be would change it.
## The verdict still stands
**The founder analysis doesn't change pass or fail.** The decision still rests on the [main comparison](pilot-spec.md#the-decision-rule).
That's on purpose. If the lock keeps the strongest founders away, that's part of what the lock *does*. It isn't an excuse for the co-ops, and a bigger program would face it too. And a second, adjusted test that could turn a fail into a pass would be the easiest way to rescue a failing pilot.
What changes is what the verdict says. It has to use one of these sentences, fixed in advance, and the fast-track bill after a pass has to print the whole sentence:
| Main comparison | Once founders are matched | The verdict says |
| --- | --- | --- |
| Passes | The gap barely moves | Passed. Founders' backgrounds don't explain it. |
| Passes | Co-ops fall behind | Passed, but co-op founders had more past pay or experience than the comparison firms'. Co-ops with similar founders haven't been shown to hold their own. |
| Fails | Most of the gap closes | Failed. Under this lock, co-ops drew founders with less past pay or industry experience, and that accounts for most of the gap. That's part of the result, not an excuse for it. |
| Fails | The gap barely moves | Failed, and founders' backgrounds don't explain it. |
| Either | Too few to tell | The main result, and "whether founders explain it couldn't be told." |
**A fail is a fail in every row.** The shutdown happens by itself. Nothing on this page is grounds to restart the bank. Trying again would take a new law and a new pilot held to the same decision rule. What the wording does is keep anyone from quoting one row as another: "co-ops don't work" and "co-ops under this lock don't attract founders with experience" are different findings, and a future design would need to fix different things.
## Watching the founders who stay
Founders who leave early can also sink a co-op. The pilot already publishes how often higher-paid members leave ([Who Sets the Pay](pay.md)). It now also publishes:
- **how often founders leave** in each of the first five years, against owners of the comparison firms who leave *without* a sale, since co-op founders can't sell their way out. A founder whose hours fall below the set level counts as having left, even if still on the books;
- **how often the best-paid members leave**, with the comparison firms' best-paid employees as the yardstick;
- **what founders who leave earn next,** against both the comparison owners and their own pay before founding. Earning more after a co-op could mean the brain drain the Uruguayan study found, or skills learned there. The report can't tell those apart, and says so.
The yearly audit of rejected applications ([Making It Last](durability.md#rigging-the-result)) also checks whether founders' past pay predicts approval once the business plan's numbers are held fixed. A bank trying to make step 1 look good would show up there.
## Asking the ones who didn't come
Records only cover founders who applied. The ones who went conventional, and the workers who never started anything, are invisible to the bank. So, once, in year 3, a federal statistical agency, not the bank, runs a voluntary survey with confidential answers. It samples new SBA borrowers in the same industries and workers in those industries who haven't started a business, in several languages.
- **Both deals are described side by side,** including the SBA's personal guarantee and the co-op's smaller downside as well as the lock.
- **The wording is written by the reviewers, filed at year 0 and tested in advance.** Each person is randomly shown one of two framings, and both results are published, so the wording's effect is visible instead of hidden.
- **The response rate is published,** with who didn't answer. No names go to the bank.
A survey answer isn't a decision made with real money, and people say things they wouldn't do. So it's published as background, and nothing in the verdict depends on it.
## What this doesn't solve
- **It measures the founder problem. It doesn't fix it.** If the analysis shows the strongest founders stayed away, the pilot has an answer, not a cure. The cure is in the founder deal ([The Founder Problem](founder-problem.md#the-tools)), and it may not be enough.
- **Records measure chances as much as ability.** Past pay reflects discrimination, bargaining power and who you know, not only skill. The report breaks the founder gap down by race, gender and immigrant status and uses careful wording, but "less past pay" is still partly "fewer chances."
- **Records miss people.** Recent immigrants may have little US record. Cash work and some self-employment never show up. The report says how many founders on each side couldn't be matched and shows them separately.
- **It's a lot of statistics on a small sample.** With about 400 co-ops, "too few to tell" is a likely answer for step 2, and the page says so in advance rather than after.
- **Worker groups muddy the comparison.** When the founders are ten workers from the same shop, the "strongest founder" is often a lead worker, not an entrepreneur. Those co-ops are reported separately.
- **It can't see the founders who never considered co-ops.** The survey is the only window on them, and it's a weak one.
@@ -0,0 +1,112 @@
---
title: Who the Bank Lends To
section: Growing the Sector
order: 66
summary: A bank that lends only to co-ops can still drift toward the safest ones, so part of the loan money is set aside for co-ops in lower-paid work, lending is published by group, and the final verdict has to say which kinds of co-ops it covers.
---
# Who the Bank Lends To
The bank's charter says it lends only to worker co-ops. That stops it from turning into an ordinary bank. It doesn't stop a quieter kind of drift: lending mostly to the co-ops that were always the safest bets, like consultancies and software shops started by people with savings, and seldom to the home care, cleaning and food co-ops where many co-op members work today.
That drift matters for two reasons. It would make the test less useful, because a pass earned by consultancies says little about the work most people do. And it's the drift the founding plan predicts. A public lender judged on its losses has every reason to fund the safe bets. The plan calls that "risk-aversion under political accountability."
## What we know
- **It has happened here.** The law that created America's co-op bank in 1978 told its board to use its "best efforts" to keep at least 35% of its loans going to co-ops of low-income people. Nothing happened when it missed, and the bank drifted toward borrowers that could have borrowed anywhere ([Has This Been Tried?](precedents.md#americas-co-op-bank-the-national-consumer-cooperative-bank)). Lending to safer borrowers may also be part of why it survived. That's the trade this page has to manage.
- **It isn't automatic.** A study of rated microfinance lenders in many countries over 11 years found that, across the industry, average loan size didn't grow and lending didn't shift toward cities. But lenders whose costs rose tended to make bigger loans, because small loans cost more to make per dollar lent ([Mersland and Strøm, *World Development*, 2010](https://doi.org/10.1016/j.worlddev.2009.05.006); [free copy](https://zenodo.org/records/16910149)). The pressure to drift is real, and it comes through costs.
- **The sector may already be moving.** The 2025 survey of US worker co-ops found that the fastest-growing industry was business consulting ([US Federation of Worker Cooperatives, 2025](https://www.usworker.coop/blog/the-2025-state-of-the-sector-report-is-here/)). Only 242 co-ops answered the survey ([survey page](https://www.usworker.coop/census/)), so that's a signal, not a measurement.
- **Enforced floors exist.** To stay certified, a US community development lender has to direct at least 60% of both the number and the dollar amount of its loans to the low-income places or people it's certified to serve. It's checked every year from loan-by-loan data, with no rounding: 59.9% fails. A lender that misses in one year can still pass on its three-year record ([CDFI Fund](https://www.cdfifund.gov/node/1030631); [Dechert, 2024](https://www.dechert.com/knowledge/onpoint/2024/3/department-of-the-treasury-s-community-development-financial-ins0.html)). Those lenders exist to serve that market, though. This bank exists to run a test, so it copies the idea of a checked, published share, not the 60%.
## Two groups, fixed in advance
Before the first loan, the pilot's filed plan defines two groups the results are reported on. The reviewers, not the bank, decide which group a co-op falls in when it applies.
- **Co-ops in lower-paid work.** The co-op's industry has to pass two checks. Nationally, its average pay is below the average for all private-sector jobs ($75,850 in 2024). In the co-op's county, its average pay is below the county's average for all private-sector jobs. The figures come from the federal count of employment and wages (the [Quarterly Census of Employment and Wages](https://www.bls.gov/cew/downloadable-data-files.htm)), which covers nearly every employer, rural counties included, at the finest industry level it publishes. The details:
- **Why two checks.** I ran the county check alone on the 2024 figures. In the richest counties, like Santa Clara, San Francisco and Manhattan, the county average is so high that management consulting, accounting, architecture and engineering all count as "lower-paid" there. Those are the co-ops the set-aside exists to look past. The national check closes that. Care, cleaning, restaurants, child care and landscaping pass either way, in counties holding over 90% of those businesses. Consulting, law, software and engineering pass nowhere.
- **Why averages.** Nobody publishes a typical (median) wage for one industry in one county, so averages are what can be checked. A few high earners pull an average up, which makes the test generous: about 55% of private-sector jobs are in industries that pass both checks where they are. The figures are pay per job per year, not per hour, so long-hours work looks better paid than it is, and part-time work looks worse paid.
- **A lower tier.** Industries whose national average is below two-thirds of the all-jobs average (about $50,500 in 2024) form a lower tier, about 40% of private-sector jobs. Care, cleaning, restaurants and child care are in it; dentists' offices, trucking and remodeling aren't. **Half the set-aside, $45 million, is held for the lower tier,** under the same rules as the rest. Without that, the bank could fill the whole set-aside from the best-paid end of the group.
- **When the county figure is withheld.** About a fifth of businesses are in county-and-industry figures the government withholds for privacy, and in counties with under 10,000 private-sector jobs it's about 70%. Then the state's figure for the industry is compared with the state's average for all jobs, not with the county's, because a state figure carries city pay that a rural county average doesn't. In my test, that lets 89% of small-county businesses in nationally lower-paid industries pass, against 66% when the state figure was set against the county average. The state figure is withheld for about 1% of businesses; then only the national check applies. The report says how often each fallback was used, by count and by dollars lent.
- **Which industry.** The reviewers decide it from what the co-op actually sells: its revenue, its contracts and its members' jobs. The code it registers under for state payroll taxes is only a cross-check, since businesses largely describe themselves to get it. Staffing and "all other" catch-all codes don't qualify on their own; the co-op's real work has to. The reviewers publish the reason for each placement.
- **Members' own pay is a ceiling.** Even in a qualifying industry, the co-op's planned median hourly pay for members, counting profit shares, has to be below the median hourly wage for all jobs in its area ([BLS area wage figures](https://www.bls.gov/oes/tables.htm)). After each of its first three years, the reviewers check it against real payroll. Pay is only a way out, never a way in: a co-op outside a lower-paid industry can't qualify by paying itself little.
- **Which county and year.** The county where most of the co-op's hours are worked, by work location on its payroll records, not where members live. The figures are the average of the last three yearly releases, fixed at the start of each lending year, so nobody can time an application around a release.
- **What moves a co-op.** The group is fixed for the loan unless the co-op misstated its business, or in its first three years most of its revenue moves into work that doesn't qualify, or its members' pay goes over the ceiling. Then its loan is moved to its real group in the published results, and the results are shown both ways. The loan itself isn't penalized. Related co-ops (shared members or control, or much of their revenue from each other) are placed as one family, so a firm can't split into a lower-paid co-op that borrows and a better-paid sister that uses what it buys.
- **First-time owners without much wealth.** Most founding members, counted by their hours of work, have never owned a business, and their household wealth is below the median for people their age in the Federal Reserve's household wealth survey. Founders report their wealth in bands, sign it under penalty for false statements, and it's checked against their tax records with their consent. Founders count only if they work at least a set number of hours in the co-op's first year.
A co-op can be in both groups, one or neither.
## Money set aside
**About a third of the loan money, $90 million, is set aside for co-ops in lower-paid work.** Other co-ops can't borrow it. Nobody waits in line. Co-ops outside the group borrow from the rest of the money, the same as before. Half of it, $45 million, is held for the [lower tier](#two-groups-fixed-in-advance). That's a first guess, sized so the set-aside could fund a large share of the co-ops at the smaller loans this kind of work usually needs.
**The standards don't change.** Each loan is still judged on its own numbers by the same filed standards ([Guarding the Pilot](pilot-safeguards.md)), and the limits on any one industry and county still apply. If the set-aside and the 25% cap on one industry ever clash, the cap wins.
**Unused money goes back, but only after a check.** At the end of year 4, lower-tier money not yet lent moves to the rest of the set-aside, and set-aside money not yet lent goes back to the general pool, each only if the reviewers certify two things for the co-ops it was held for:
1. The bank decided applications from lower-paid co-ops as fast as others, measured from a complete application, within a time limit filed in advance.
2. The audit of rejected lower-paid applications found that the bank applied its own standards ([the line](durability.md#when-rejections-mean-not-tested)).
If they can't certify both, the money stays set aside until the lending window closes. The verdict for lower-paid work is then **"not tested,"** not "failed," unless the reviewers show that good applications really were scarce.
**A shortfall with a clean audit is a finding.** If good applications from lower-paid work don't come even when the money is there and the bank handled them fairly, that's evidence about where co-ops can work. It's reported that way, the same rule the pilot uses when applicants are scarce ([The Pilot, in Numbers](pilot-spec.md#why-about-400-co-ops)).
**What counts.** Only new money to a co-op counts, and loans to related co-ops are added together, so splitting one loan into pieces can't pad the numbers. If the reviewers later find a co-op was put in the wrong group, its loan is moved to its real group, and the set-aside figures are recalculated. A co-op that misstated its industry on purpose is treated like any other false statement to the bank, under the rules against sham co-ops.
This isn't a lending target. Nobody at the bank is paid or judged on how much it lends, to any group.
## Priced like any other loan
Market rate means the whole cost of the loan, including the cost of making and servicing it, as a conventional lender would charge ([What "market rate" means](pilot-spec.md#what-market-rate-means)). Small loans cost more to make per dollar, so they carry a higher rate or a fee, as they would anywhere. The yearly rate audit checks that too. The bank can match repayments to a co-op's cash flow, with longer terms or a slow start, as long as the price for the risk stays the same.
So the set-aside isn't a subsidy, and it can't excuse a result. The money test is judged on the whole loan book, with no adjustment for the set-aside, whichever way it comes out.
## Published by group
Every year the bank publishes, for each group: applications, approvals, rejections, offers the co-op turned down, withdrawals, rates, collateral, loan sizes, time to a decision, and how the loans are doing. It also publishes the same figures for the lower tier, by where the co-op's industry falls on its county's wage scale (in fifths), by industry, by rural or urban county (using the federal government's standard rural codes), and by the founders' wealth band.
To protect small businesses, every published table needs at least 10 co-ops in each cell, counting every combination of details shown. Whether individual loans are behind on payments is published only in totals. Loan-by-loan data goes only to vetted researchers under a data-use agreement.
## Results by group
The pilot still passes or fails on the whole portfolio, using the two measures filed in advance ([The decision rule](pilot-spec.md#the-decision-rule)). Adding group results to the decision would make it easier to find one that looks good.
But the final report gives both measures, survival with fair pay and the bank's money, for each group. Because the groups are much smaller than the whole pilot, the plan files in advance the smallest difference each group could reliably show. For groups of a couple of hundred co-ops, "too few to tell" is the likely answer. When it is, the report says so, with the range of possible results and no single headline number.
Differences between groups are claimed only if a test of the difference itself, filed in advance, finds one. The verdict says which kinds of co-ops it covers. "Passed, with too few lower-paid co-ops to tell" is a different result from "passed across the board," and nobody gets to quote the first as the second.
The reviewers, not the bank, work out the bank's return for each group, using a method filed in advance for splitting shared costs. They report it with and without shared overhead, so the split can't be rigged to make one group look expensive.
If the pilot passes, any larger program's lending to lower-paid work is sized from that group's own result. If that result is "too few to tell," that lending can't grow faster than the rest ([After a Pass](scale.md)).
## Treated the same
The yearly audit of rejected applications ([Making It Last](durability.md#rigging-the-result)) now also looks at the two groups, in three ways.
- **Same numbers, same answer?** It compares approvals, rates and collateral across the groups for applications with similar numbers, and checks samples of approvals in both groups against the filed standards.
- **Are the numbers themselves fair?** It checks each part of the bank's scoring, like collateral, credit history and founders' own money, to see whether it counts against a group more than it predicts repayment. Then it publishes whether a different scoring would predict repayment as well with less of that effect.
- **Is the bar too high?** If approved co-ops in one group repay better than the rest, the bank was demanding too much of that group. The lottery among borderline applicants ([The Pilot, in Numbers](pilot-spec.md#who-co-ops-are-compared-against)) is the cleanest place to check.
The audit also reports by the founders' gender and race, as the pilot already does, collected separately from the loan file so underwriters don't see it. Answering is optional and changes nothing about the loan.
**Asking needs its own legal basis.** The fair-lending law forbids discriminating, not asking ([15 U.S.C. § 1691](https://www.law.cornell.edu/uscode/text/15/1691)). The ban on asking an applicant's race, color, religion, national origin or sex is in the rules written under it, with a few exceptions ([12 C.F.R. § 1002.5](https://www.law.cornell.edu/cfr/text/12/1002.5)). The law covers government lenders too ([§ 1691a](https://www.law.cornell.edu/uscode/text/15/1691a)), so the bank is bound by both. None of the ready-made exceptions fits it:
- **The federal small-business lending data rule** requires lenders to ask, behind a wall that keeps the answers from anyone deciding on the loan ([§ 1002.108](https://www.ecfr.gov/current/title-12/part-1002/section-1002.108)). Since a May 2026 change, it covers only lenders that make at least 1,000 small-business loans in each of two years, starting in 2028 ([§ 1002.105(b)](https://www.ecfr.gov/current/title-12/part-1002/section-1002.105), [§ 1002.114](https://www.ecfr.gov/current/title-12/part-1002/section-1002.114)). The pilot's bank makes far fewer than 1,000 loans a year. Collecting early is allowed only for lenders that expect to be covered, and continuing is allowed only for lenders that used to report. The rule is also being reconsidered again.
- **A lender's own fair-lending self-test** can ask, if it tells applicants that answering is optional, why it's asking, and that the law forbids using the answer or a refusal against them ([§ 1002.5(b)(1)](https://www.law.cornell.edu/cfr/text/12/1002.5), [§ 1002.15](https://www.ecfr.gov/current/title-12/part-1002/section-1002.15)). But collection the government requires doesn't count as a voluntary self-test, and the pilot requires these figures.
- **Asking later, through the reviewers,** doesn't escape the rule: it covers "every aspect of an applicant's dealings with a creditor" about a loan ([§ 1002.2(m)](https://www.ecfr.gov/current/title-12/part-1002/section-1002.2)). And applicants who were turned down rarely answer a later survey, which would lose the very people the audit is for.
So the bank's law uses the route the rules already leave open: information "required by a regulation" to monitor compliance ([§ 1002.5(a)(2)](https://www.law.cornell.edu/cfr/text/12/1002.5)). The law directs the bank's federal regulator to issue that rule, using the categories the small-business data rule uses (ethnicity, race and sex of each principal owner), so the results can be compared with national figures if those are ever collected. The rule copies the self-test notice to applicants and goes further than the data rule's wall. That rule lets a lender skip the wall, with notice, when keeping it isn't feasible, and a bank with a few loan officers would. So the bank never holds the answers at all: a separate custodian collects them under coded IDs and matches them to loan files only after decisions are final. As a fallback, the law authorizes the question directly, "notwithstanding" the rules. A deal with a single agency wouldn't do: it would last only as long as that agency's goodwill. The bank asks nothing about birthplace or language.
- **Kept apart, kept briefly.** They're used only for the audit and the pilot's published results, never shared with law enforcement or immigration authorities, and kept out of public-records requests. Each answer is deleted three years after the audit that used it, the data rule's own retention period ([§ 1002.111](https://www.ecfr.gov/current/title-12/part-1002/section-1002.111)). Results for fewer than ten people aren't published, and refusal rates are.
- **Publishing has a price, paid on purpose.** Published results lose the protection the self-test rule gives a lender's own findings, and anyone suing the bank can use them. That's the point of an audit the public can check. The test a replacement scoring rule has to pass is published in advance, so turning one down is a rule being followed, not a judgment call.
- **If Congress won't include it,** estimates from surname and address, the method the Consumer Financial Protection Bureau uses, can stand in for totals. They say nothing about sex, work poorly for small groups and for co-ops with several founders, and can't be checked by the people they describe. A better fallback is matching founders, with their consent, to their own answers on the census, inside the Census Bureau's secure research program, so the bank never holds a race file. The reviewers compute the surname-and-address estimates from the first year anyway, so if a later Congress repeals the question, there's a comparable series to continue.
Race and sex measure results; they never decide a loan. If the audit finds a scoring rule that works against a group, a replacement is adopted only if it predicts repayment as well or better for all applicants. That's the bank's own rule, not a claim about what the courts require, so it holds whatever happens to the law on unintended discrimination. Gaps by race are reported both as they are and after taking founders' wealth into account, so it shows how much of any gap runs through wealth and how much doesn't.
## What this doesn't solve
- **It can't make people apply.** The set-aside makes money available. It can't make groups come forward. The [founding lottery](before-launch.md) and sponsors like unions and worker centers do that, and the pilot can only measure how well.
- **The lower-paid group is wide.** It runs from the lowest-paid work to work just under the average, about 55% of private-sector jobs. The lower tier holds half the set-aside for the bottom of it, but inside each half the bank can still lend mostly at the top. The results are published by fifths of the local wage scale, so that shows.
- **Market prices aren't neutral.** In home care and cleaning, prices are largely set by whoever buys the work, like Medicaid or a contractor, not by how well the business is run. A co-op that fails there may be failing the price, not the co-op form. Comparing co-ops with conventional firms in the same industry catches some of that, not all.
- **Industry is a rough stand-in.** A co-op in a low-wage industry can still pay well; the pay ceiling catches that. The reverse isn't caught: a rural design or computer-repair co-op whose members earn little fails the national check, and borrows from the general pool on the same terms. Letting co-ops in on their own low pay would reward paying yourselves little at application, so it isn't allowed. A determined co-op can still describe its work to fit, and the reviewers' placement is a judgment. The pilot publishes members' actual pay too.
- **Wealth is hard to check.** Tax records show income, not savings. Some founders will understate their wealth.
- **The numbers are guesses.** $90 million, the half for the lower tier, its two-thirds line, three years of pay checks and year 4 are first guesses. The pilot publishes how much of the set-aside was used, so the next design can do better.
@@ -0,0 +1,31 @@
---
title: Worker Ownership
section: The Co-op
order: 20
summary: What a worker co-op means here: one member, one vote from day one, and a profit share that grows with tenure and then caps.
---
# Worker Ownership
A worker co-op under this model means two things, and they are not the same thing:
**One worker, one vote. Unconditional, from membership.** This is the moral core and it does not ramp, vest, or vary. The objection to the current system is *domination*: being ruled by a distant firm that answers to nobody who works there. The cure for domination is the vote, and you don't earn your way into not being dominated.
**Profit share ramps with tenure, then caps.** A new member starts at a partial share and grows to a full, equal share over roughly five to ten years. After the cap, everyone's share is identical. The thirty-year lifer and the eight-year veteran split profit the same.
Why the ramp: it kills the dilution panic. Under flat equal shares, every new hire immediately eats everyone's slice, and cooperatives empirically under-hire for exactly that reason. With a ramp, hiring decisions move toward "does this person grow the pie" during precisely the window when their value is most uncertain. The ramp only softens the problem, though. How co-ops can grow into lower-margin work without shrinking everyone's share is in [When Growing Means Sharing](growth.md).
Federal tax law narrows what the ramp can do. Profit shares are counted per hour worked, and the part of a share a new member hasn't reached yet can't go to the other members without being taxed twice. So by default it goes to the co-op's permanent reserve. The ramp still makes new members earn their way to a full share, but it no longer protects everyone else's share from new hires, unless a co-op pays the difference as wages for years of service ([How a Locked Co-op Is Taxed](taxes.md#the-tenure-ramp)).
Protected leave (parental, medical, caregiving) counts as tenure, so taking time off to care for someone doesn't set your share back. So does earlier time at the same co-op: someone who leaves and comes back within a few years picks up where they left off.
Membership itself comes automatically after a short probation, unless a worker opts out. In jobs like home care, where most people move on within a year or two, that's what keeps a co-op from becoming a club of the few who stay ([When People Don't Stay Long](high-turnover.md)).
Where does the profit share actually go? Part is paid in cash each year, and part goes into a personal account that pays each year's share back after about five years. See [Member Capital Accounts](capital-accounts.md).
Why the cap: uncapped tenure weighting builds a gerontocracy of shares and chains people to their first employer. Capped at a career-fraction, people can change companies, move cities, start over, and still reach a full share with plenty of career left.
- **Base salaries still exist**, set by the company. A brain surgeon won't work somewhere without a high salary, and shouldn't have to. Hard jobs and in-demand jobs still pay more. The incentive to do difficult work survives intact. Pay isn't capped, but raising salaries doesn't let members skip the co-op's reserve: it takes its share of pay above the going rate too ([Investing for the Long Run](the-long-run.md)). How pay gets set, in the open and by the right people, is in [Who Sets the Pay](pay.md).
- **Governance is up to each company.** Workers pass bylaws deciding what requires a vote and what doesn't. Some will use direct democracy, some representative systems, some will delegate almost everything to management they hire and can fire. "Workers vote on every decision" is a strawman and always was. Why anyone works hard when the profit is split so many ways, and what happens when someone doesn't, is in [Does Sharing the Profit Mean Less Effort?](free-riding.md).
The concrete case: the average employee where I work generates roughly $150,000/year in profit after all costs. That money currently flows upward to private equity. A firm with those economics, built as a co-op, sends it to the people who made it. The numbers, using public figures, are in [What If Conservice Had Been a Co-op?](conservice.md).
+9
View File
@@ -9,9 +9,18 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Markdig" Version="1.4.0" />
<PackageReference Include="System.Text.Json" Version="9.0.3" />
</ItemGroup>
<ItemGroup>
<!-- Public /socialism documents. Underscore-prefixed files (founding draft, progress log) are private and never deployed. -->
<Content Include="Content/Socialism/*.md" Exclude="Content/Socialism/_*.md">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<Content Include="Resources/**">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+15
View File
@@ -0,0 +1,15 @@
namespace JoshHeaps.Net.Models;
public class SocialismDoc
{
public string Slug { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Summary { get; set; } = string.Empty;
public string Section { get; set; } = string.Empty;
public int Order { get; set; }
public string HtmlContent { get; set; } = string.Empty;
public List<SocialismHeading> Headings { get; set; } = [];
public int ReadingMinutes { get; set; }
}
public record SocialismHeading(int Level, string Id, string Text);
+303 -32
View File
@@ -7,27 +7,58 @@
<div id="holdemApp">
<header id="holdemHeader">
@* Six equally-weighted buttons used to sit here in two rows, and on a 390px
phone the felt did not start until 225px down — a quarter of the screen spent
before the dealer saw the table. Only two of the six are per-hand controls.
The rest have gone where they belong rather than into a "more" menu, which
would have relocated the crowding rather than removed it: Reset Table down to
the sidebar with the save/load pair — in a panel called Save & reset, so the
danger is on the label rather than at the end of a scroll — Quick Split down
with Common rulings where the other "open something else" link already lives,
and the way back to the site up top where a back link goes. *@
@* The word is its own element so a phone can drop it and keep the arrow, and the
aria-label is what a screen reader gets either way. It contains the visible
text on the sizes that still show it, which is what WCAG 2.5.3 asks of a name
that says more than the label does. *@
<div id="holdemHeaderLeft">
<a href="/casino" class="holdemBackLink" aria-label="Back to Casino">←<span class="backLinkWord"> Casino</span></a>
<h1>Texas Hold'em Dealer</h1>
<p class="holdemSubtitle">Seat your players, track the action, split the pot.</p>
<span id="handCounterBadge" class="handCounterBadge">Hand #1</span>
</div>
<div id="holdemHeaderActions">
<a href="/casino/texasholdem/quicksplit" class="holdemBtn" title="Chop a pot without setting up a table">Quick Split</a>
<button id="misdealBtn" class="holdemBtn" title="Take this hand back — every chip returns to the player who put it in, and the same button deals again" hidden>Misdeal</button>
<button id="nextHandBtn" class="holdemBtn" title="Move the button, small blind and big blind to the next occupied seat">Next Hand ⟳</button>
<button id="resolvePotBtn" class="holdemBtn holdemBtnAccent">Resolve Pot</button>
<button id="resetTableBtn" class="holdemBtn holdemBtnDanger">Reset Table</button>
<a href="/casino" class="holdemBtn holdemBtnGhost">← Casino</a>
@* Not the accent any more. It is a shortcut to a sheet, available all night,
and it is never the next step — the bar always has that, and at showdown
the bar says so in as many words. See .actionAdvance in holdem.css. *@
<button id="resolvePotBtn" class="holdemBtn">Resolve Pot</button>
</div>
</header>
@* The seat sheet was the only one of the tool's seven overlays with nothing behind
it. On a phone it covers the felt from 250px down and the action bar with it, so
it is modal in everything but appearance — and what stayed lit above it was the
header, with Misdeal, Next Hand and Resolve Pot live under a thumb that was
reaching for a name field. NN/g and Material both say the same thing about this:
a modal sheet with an invisible scrim tells the user they can still touch what
they can still see, and they can't.
Not an `*Overlay` id, deliberately — Modal watches those and would take the
sidebar panel this becomes on a desktop for a dialog. It is driven entirely by
body.seatSheetOpen, and it is `display: none` above 700px where the panel is
just a panel. *@
<div id="seatSheetScrim"></div>
<div id="holdemLayout">
<section id="tableSection">
<div id="boardStrip" hidden></div>
<div id="pokerTableWrap">
<div id="pokerTable">
<div id="potBadge">
<span id="potBadgeLabel">Pot</span>
<span id="potBadgeAmount">$0</span>
<div id="tableCentre">
<div id="boardRow" hidden aria-label="Community cards"></div>
<div id="potBadge">
<span id="potBadgeLabel">Pot</span>
<span id="potBadgeAmount">$0</span>
</div>
</div>
</div>
<div id="betsLayer" aria-hidden="true"></div>
@@ -36,6 +67,7 @@
<p id="tableHint" class="tableHint"></p>
<p class="tableHintActions">
<button type="button" id="openRulingsBtn" class="linkBtn">Common rulings</button>
<a href="/casino/texasholdem/quicksplit" class="linkBtn" title="Chop a pot without setting up a table">Quick Split</a>
</p>
</section>
@@ -51,8 +83,12 @@
</div>
</section>
<details class="sidebarPanel" open>
<summary>Table Setup</summary>
@* Not `open` in the markup any more. Whether this panel starts open is a
question about the table, so main.js answers it after the state has
loaded: open on a table nobody has set up yet, shut on one already
half-way through a night. *@
<details class="sidebarPanel" id="tableSetupPanel">
<summary><h2>Table Setup</h2> <span class="panelGlance" id="glanceTableSetup" hidden></span></summary>
<div class="panelBody">
<label class="fieldRow">
<span>Seats at table</span>
@@ -70,6 +106,22 @@
<input type="checkbox" id="dedicatedDealerToggle" />
</label>
<p class="fieldHint">Turn this on if a person outside the game deals — no seat shows the dealer button, but blinds still rotate automatically each hand.</p>
<label class="fieldRow">
<span>Ask me for the cards as they come out</span>
<input type="checkbox" id="trackBoardToggle" />
</label>
@* The label used to lead with "Track the board", which is the feature's name
rather than what pressing it does to your evening. What it does is put a
card sheet in front of you at four moments: each street, and the showdown. *@
<p class="fieldHint">Every time you deal a street, a sheet asks which cards came off the deck, and puts them on the felt. At the showdown it asks for each hand that turns over &mdash; then Resolve Pot reads the winner and the amount instead of asking you. Close the sheet at any point to go back to picking the winner yourself.</p>
<label class="fieldRow">
<span>Keep the screen on during a hand</span>
<input type="checkbox" id="keepScreenAwakeToggle" />
</label>
@* On by default, and the only setting here that is. A dealer has to
opt in to the tool doing more than they asked; they should not have
to opt in to it staying readable. *@
<p class="fieldHint">Stops the phone dimming and locking between the flop and the turn. It only holds while betting is open, so the screen sleeps as usual during breaks &mdash; and the tool says so the first time it takes hold.</p>
<div class="blindsRow">
<label class="fieldRow">
<span>Small blind</span>
@@ -86,6 +138,32 @@
</label>
<p class="fieldHint">Blinds post themselves each hand. Seats without their own stack start here.</p>
<details class="subPanel">
<summary>House take</summary>
<div class="subPanelBody">
<div class="blindsRow">
<label class="fieldRow">
<span>Rake (%)</span>
<input type="number" id="rakePercentInput" min="0" max="100" step="0.5" placeholder="0" />
</label>
<label class="fieldRow">
<span>Cap ($)</span>
<input type="number" id="rakeCapInput" min="0" step="1" placeholder="none" />
</label>
</div>
<label class="fieldRow">
<span>No flop, no drop</span>
<input type="checkbox" id="rakeNoFlopInput" />
</label>
<p class="fieldHint">
Taken from the pot when it's awarded, and shown before anything is pushed.
Rounded down to a chip the table has, so the players keep what the house
can't make. Leave the percentage at zero for a game that doesn't rake.
</p>
<p class="fieldHint" id="rakeTakenHint" hidden></p>
</div>
</details>
<details class="subPanel">
<summary>Without live betting</summary>
<div class="subPanelBody">
@@ -99,11 +177,23 @@
</div>
</details>
@* These four were one panel called "Session", which is a container word rather
than a name: it held the standings, a waiting list, the hand log and the
save/load/reset buttons — a topic, a task, a log and three utilities, which
is four kinds of thing under one label. It measured at 1206px and 103
elements, and "The table itself" was 1052px down inside it, so reaching
Reset table meant opening a panel called Session and scrolling past the
money and the log to get there.
Each panel is named for the thing a dealer came looking for, and the
subheads that used to do that job are gone, because the summary says it
now. They are in the order of a night: set the table up, watch the money,
fill the seats, look back at a hand — and, last and apart from the rest,
the two buttons that end it. *@
<details class="sidebarPanel">
<summary>Session</summary>
<summary><h2>Standings</h2> <span class="panelGlance" id="glanceStandings" hidden></span></summary>
<div class="panelBody">
<p id="storageWarning" class="storageWarning" hidden></p>
<h3 class="panelSubhead">Standings</h3>
<div class="standingsTableWrap">
<div id="standingsTable">
<div class="standingsRow standingsHeader">
@@ -132,7 +222,16 @@
<span>Taken off the table</span>
<span id="bankDiscarded"></span>
</div>
<div class="bankReconcileRow" id="bankRakedRow" hidden>
<span>House take</span>
<span id="bankRaked"></span>
</div>
<div class="bankReconcileRow" id="bankPaidOutRow" hidden>
<span>Paid out</span>
<span id="bankPaidOut"></span>
</div>
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
<button type="button" id="bankRecordBuyInsBtn" class="holdemBtn holdemBtnSmall" hidden></button>
</div>
<div id="settleUp" hidden>
@@ -140,8 +239,24 @@
<ul id="settleUpList" class="settleUpList"></ul>
<p id="settleUpNote" class="fieldHint"></p>
</div>
</div>
</details>
<h3 class="panelSubhead">Hands</h3>
<details class="sidebarPanel">
<summary><h2>Waiting list</h2> <span class="panelGlance" id="glanceWaiting" hidden></span></summary>
<div class="panelBody">
<form id="waitingListForm" class="waitingAdd">
<input type="text" id="waitingNameInput" maxlength="24" placeholder="Who's waiting?" autocomplete="off" />
<button type="submit" id="waitingAddBtn" class="holdemBtn holdemBtnSmall">Add</button>
</form>
<ol id="waitingList" class="waitingList"></ol>
<p id="waitingEmptyHint" class="fieldHint">Nobody waiting. Add the people standing behind the chairs, and the tool will offer them the next seat that opens.</p>
</div>
</details>
<details class="sidebarPanel">
<summary><h2>Hands</h2> <span class="panelGlance" id="glanceHands" hidden></span></summary>
<div class="panelBody">
<div class="handHistoryFilterRow">
<input id="handHistoryFilter" type="search" class="handHistoryFilter"
placeholder="Find a hand — name, amount or #" aria-label="Filter the hand log by player, amount or hand number" />
@@ -159,7 +274,7 @@
</details>
<details class="sidebarPanel">
<summary>Chip Legend</summary>
<summary><h2>Chip Legend</h2> <span class="panelGlance" id="glanceChips" hidden></span></summary>
<div class="panelBody">
<div id="chipLegendList"></div>
<button id="addChipColorBtn" class="holdemBtn holdemBtnSmall">+ Add chip color</button>
@@ -174,7 +289,7 @@
</details>
<details class="sidebarPanel" id="keyboardPanel">
<summary>Keyboard</summary>
<summary><h2>Keyboard</h2></summary>
<div class="panelBody">
<dl id="shortcutLegend" class="shortcutLegend"></dl>
<p class="fieldHint">Nothing fires while you're typing in a field, or while a sheet is open over the table.</p>
@@ -182,11 +297,45 @@
</details>
<details class="sidebarPanel">
<summary>Table Notes</summary>
<summary><h2>Table Notes</h2> <span class="panelGlance" id="glanceNotes" hidden></span></summary>
<div class="panelBody">
<textarea id="tableNotes" rows="4" aria-label="Table notes" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
</div>
</details>
@* Last, and on its own, because everything in it is about the night as a whole
rather than the hand in front of you — and because two of the three buttons
are the ones you cannot take back.
Reset table came down from the header a while ago, where it sat in the same
weight as Resolve Pot: the most-pressed control in a session beside the one
that throws the night away. It used to be buried at the bottom of Session,
1052px down, which hid it but also meant nobody could find it on purpose.
Naming the panel for both jobs puts the danger on the label, where it is
read before the panel is opened rather than met at the end of a scroll. *@
<details class="sidebarPanel">
<summary><h2>Save &amp; reset</h2></summary>
<div class="panelBody">
<p class="fieldHint">
The night lives in this browser and nowhere else. Save it to a file to carry it
to another device, or to have something to come back to if this one dies.
</p>
@* That sentence has always been true, and always been missing its
second half: a browser is allowed to throw site data away when the
device runs short, and Safari does it on a timer. So the panel now
says which of the two situations this device is actually in, and
offers the one thing that changes it. See requestDurable() in
state.js for why the ask is a button rather than a page-load call. *@
<p class="fieldHint" id="storageDurabilityHint" hidden></p>
<button id="keepStorageBtn" class="holdemBtn holdemBtnSmall" hidden>Ask this browser to keep it</button>
<div class="handHistoryActions">
<button id="saveTableBtn" class="holdemBtn holdemBtnSmall">Save table file</button>
<button id="loadTableBtn" class="holdemBtn holdemBtnSmall">Load table file</button>
<input id="loadTableInput" type="file" accept="application/json,.json" hidden />
<button id="resetTableBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Reset table</button>
</div>
</div>
</details>
</aside>
</div>
</div>
@@ -197,6 +346,7 @@
<button class="dealerChip" type="button" title="Make this seat the dealer button">D</button>
<span class="blindBadge sbBadge">SB</span>
<span class="blindBadge bbBadge">BB</span>
<span class="blindBadge strBadge" title="Straddled — the action comes back to this seat">STR</span>
<div class="seatPuckName"></div>
<div class="seatPuckCards"></div>
<div class="seatPuckTotal"></div>
@@ -204,6 +354,20 @@
</div>
</template>
@* Four things stacked flat used to be five: name, in/out, two hole-card slots, stack,
a five-box chip counter and a list of custom fields, every one of them at the same
weight, all of it open at once. Measured on a 390px phone the sheet came to 594px,
and the three controls a dealer touches on nearly every visit — the name, the two
status boxes, the stack — were 132px of it. The chip counter alone was 174px, the
tallest thing in the sheet, for a job most tables do once a night if at all; the two
card slots were 62px of dashed grey on every table that never asked to be shown a
card.
So the flat list is now three rows and three disclosures, and which of the three
stand open is a question about *this seat*, answered in renderSeatDisclosures: a
seat holding cards opens its cards, a seat with chips counted opens its count, a
seat with notes on it opens its notes. Anything with nothing in it stays a one-line
summary that says so. Nothing was removed and nothing moved to another screen. *@
<template id="seatEditorTemplate">
<div class="seatEditorForm">
<label class="fieldRow">
@@ -220,25 +384,45 @@
<span>Fold</span>
</label>
</div>
<div class="seatCards">
<button type="button" class="cardSlot card1Slot" aria-label="Choose first card"></button>
<button type="button" class="cardSlot card2Slot" aria-label="Choose second card"></button>
</div>
<label class="fieldRow stackRow">
<span>Stack</span>
<input type="number" class="stackInput" min="0" step="1" placeholder="table default" />
</label>
<div class="seatBuyIns" hidden></div>
<div class="seatContribution">
<div class="chipCounts"></div>
<label class="fieldRow amountOverrideRow">
<span>$ amount</span>
<input type="number" class="amountOverrideInput" min="0" step="1" placeholder="auto" />
</label>
<div class="seatTotal">Total: <span class="seatTotalAmount">$0</span></div>
</div>
<div class="customFields"></div>
<button class="holdemBtn holdemBtnSmall addFieldBtn">+ field</button>
<details class="subPanel seatSubPanel cardsPanel">
<summary><span class="seatSubPanelName">Hole cards</span> <span class="seatGlance cardsGlance"></span></summary>
<div class="subPanelBody">
<div class="seatCards">
<button type="button" class="cardSlot card1Slot" aria-label="Choose first card"></button>
<button type="button" class="cardSlot card2Slot" aria-label="Choose second card"></button>
</div>
</div>
</details>
@* Named for the question it answers rather than for the boxes it contains. The
counter, the typed override and the total are one calculation — what this
seat has put in the pot — and the answer now sits on the summary, so a shut
panel still says it. *@
<details class="subPanel seatSubPanel contributionPanel">
<summary><span class="seatSubPanelName">What they've put in</span> <span class="seatGlance contributionGlance"></span></summary>
<div class="subPanelBody seatContribution">
<div class="chipCounts"></div>
<label class="fieldRow amountOverrideRow">
<span>$ amount</span>
<input type="number" class="amountOverrideInput" min="0" step="1" placeholder="auto" />
</label>
<div class="seatTotal">Total: <span class="seatTotalAmount">$0</span></div>
</div>
</details>
<details class="subPanel seatSubPanel notesPanel">
<summary><span class="seatSubPanelName">Notes on this seat</span> <span class="seatGlance notesGlance"></span></summary>
<div class="subPanelBody">
<div class="customFields"></div>
<button class="holdemBtn holdemBtnSmall addFieldBtn">+ field</button>
</div>
</details>
</div>
</template>
@@ -250,9 +434,14 @@
</div>
</template>
@* The value used to be a `title` attribute, which is a tooltip — and a tooltip is
nothing at all on the device this is used on. Five coloured dots with five number
boxes and no way to tell which one was the $25 without opening the Chip Legend and
matching colours by eye. It is text now. *@
<template id="chipCountTemplate">
<label class="chipCountRow">
<span class="chipSwatch"></span>
<span class="chipCountValue"></span>
<input type="number" class="chipCountInput" min="0" step="1" value="0" />
</label>
</template>
@@ -349,6 +538,40 @@
</p>
</section>
<section class="ruling" data-short="Clock">
<h3>Calling the clock on a player</h3>
<p>
Any player may ask for a clock once a decision is taking unreasonably
long; it is the dealer, not the table, who grants it. The player then
gets a set time and a countdown out loud — thirty seconds and ten is the
usual form.
</p>
<p>
If the count reaches zero with no action, the hand is <em>dead</em> if
they were facing a bet, and <em>checked</em> if they were not: nobody is
folded for slowness when checking was free. The action bar runs the
count once you tap <em>Call the clock</em>, and offers the ruling as one
button when it ends.
</p>
</section>
<section class="ruling" data-short="Straddle">
<h3>A straddle is a blind raise</h3>
<p>
The player left of the big blind may put out twice it before the cards,
without looking. It is a blind bet, not a raise made in turn, so it does
three things at once: everyone else acts first, the minimum raise becomes
twice the straddle, and the straddler still has the option when the
action comes back — they may raise even if nobody raised into them.
</p>
<p>
Re-straddles carry on to the left, each doubling again, and stop before
the small blind. Tap <em>Straddle</em> on the deal prompt and the tool
posts it, puts the right player on the clock and prices the raise;
the seat wears an <em>STR</em> badge for the hand.
</p>
</section>
<section class="ruling" data-short="Short all-in">
<h3>A short all-in doesn't reopen the betting</h3>
<p>
@@ -475,7 +698,17 @@
</div>
</div>
<div id="actionBar" aria-live="polite" hidden></div>
@* No aria-live here any more. This element's entire contents are rebuilt on every
action, so a live region on it announced the whole bar every time — measured, after
one tap: "PRE-FLOP Pot $15 9 players Bob · $250 behind 0:00 ↶ dealing the hand U
Fold Call $10 Raise". That is what a screen reader read out after every single fold.
A live region has to be a stable element whose *text* changes; the one below is. *@
<div id="actionBar" hidden></div>
@* Whose turn it is, in a sentence. Empty in the markup and only ever written to, so
assistive tech is already watching it when the first update lands — a live region
created at the moment it has something to say is one the screen reader misses. *@
<div id="actionAnnouncer" role="status" aria-live="polite" class="visuallyHidden"></div>
<div id="toastHost" aria-live="polite"></div>
@@ -497,20 +730,58 @@
<button id="closePotWizardBtn" data-modal-close class="holdemBtn holdemBtnGhost">✕</button>
</div>
<div id="potWizardBody"></div>
@* The buttons live outside the scrolling body on purpose. This is the one
dialog that divides money, and its Confirm sat below a scroll at every screen
size — 244px off the bottom on an iPhone, 583 on a phone held sideways. A
sticky footer cannot fix that from inside: the actions were the last child of
the scrolling content, so their containing block ended exactly where they did
and `bottom` had no room to lift them. Out here they simply don't scroll. *@
<div id="potWizardFooter"></div>
</div>
</div>
@section Styles {
<link href="/css/casino/holdem.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
@* Installable, and works with no signal — see the service worker registration at the
foot of main.js. A dealer running a night in somebody's back room is on the worst
network there is, and the tool being a *website* was the last thing that could
take it away from them mid-game. *@
<link rel="manifest" href="/casino/holdem.webmanifest" />
@* iOS reads none of the manifest's icons and only this. *@
<link rel="apple-touch-icon" href="/images/casino/holdem-apple-touch.png" />
@* The standard name first, then Apple's older one. Chrome has been printing
"<meta name="apple-mobile-web-app-capable"> is deprecated" into this page's
console on every load; the replacement is the unprefixed name, and Safari still
wants the prefixed one, so both are here. *@
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Hold'em" />
@* Paints the phone's chrome the same near-black as the felt's ground, so an
installed copy doesn't sit under a white status bar. *@
<meta name="theme-color" content="#141414" />
}
@* A note for whoever reads the ?v= below and assumes it works: it doesn't. ViewData
["cssVersion"] is assigned in _Layout's own code block, which runs after these
sections are handed their values, so every one of these renders as a bare "?v=" —
on this page and on Chess, Particles and Memory Lane too. It is inert rather than
broken: Program.cs sends `Cache-Control: no-store` on every static file, so nothing
is being browser-cached for a version string to bust in the first place.
Left alone deliberately. Fixing it is a one-line change to a file every page on the
site shares, and the service worker was the reason to care — that now uses stale-
while-revalidate, which needs no version to stay current. *@
@section Scripts {
<script src="~/js/casino/holdem/money.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/feedback.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/potCalculator.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script>
@* After feedback.js, because the first lock it takes announces itself with a Toast. *@
<script src="~/js/casino/holdem/wakeLock.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/settleUp.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/handRank.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/cardPicker.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/seatMenu.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/addChips.js?v=@ViewData["cssVersion"]"></script>
+60
View File
@@ -0,0 +1,60 @@
@page "/socialism/{slug}"
@model JoshHeaps.Net.Pages.Socialism.DocModel
@{
ViewData["Title"] = Model.Doc!.Title + " - JoshHeaps.Net";
Layout = "_Layout";
}
<div class="soc-progress" id="socProgress"></div>
<button class="soc-menu-toggle" id="socMenuToggle" aria-controls="socSidebar" aria-expanded="false">Contents</button>
<div class="soc-shell">
<partial name="_Nav" model="Model" />
<div class="soc-main">
<nav class="soc-breadcrumbs" aria-label="Breadcrumb">
<a href="/socialism">Overview</a>
@if (!string.IsNullOrEmpty(Model.Doc.Section))
{
<span>/</span><span>@Model.Doc.Section</span>
}
</nav>
<header class="soc-doc-header">
<h1 class="soc-doc-title">@Model.Doc.Title</h1>
@if (!string.IsNullOrEmpty(Model.Doc.Summary))
{
<p class="soc-doc-summary">@Model.Doc.Summary</p>
}
<span class="soc-doc-meta">@Model.Doc.ReadingMinutes min read</span>
</header>
<article class="soc-content">
@Html.Raw(Model.Doc.HtmlContent)
</article>
<nav class="soc-pager" aria-label="Previous and next">
@if (Model.Previous is not null)
{
<a class="soc-pager-link prev" href="@(Model.Previous.Slug == SocialismDocService.IndexSlug ? "/socialism" : "/socialism/" + Model.Previous.Slug)">
<span class="soc-pager-label">&larr; Previous</span>
<span class="soc-pager-title">@(Model.Previous.Slug == SocialismDocService.IndexSlug ? "Overview" : Model.Previous.Title)</span>
</a>
}
@if (Model.Next is not null)
{
<a class="soc-pager-link next" href="/socialism/@Model.Next.Slug">
<span class="soc-pager-label">Next &rarr;</span>
<span class="soc-pager-title">@Model.Next.Title</span>
</a>
}
</nav>
</div>
<partial name="_Toc" model="Model.Doc.Headings" />
</div>
@section Styles {
<partial name="_Head" />
}
@@ -0,0 +1,31 @@
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Implementations;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace JoshHeaps.Net.Pages.Socialism;
public class DocModel(ISocialismDocService docService) : SocialismPageModel
{
public SocialismDoc? Doc { get; private set; }
public SocialismDoc? Previous { get; private set; }
public SocialismDoc? Next { get; private set; }
public IActionResult OnGet(string slug)
{
if (slug == SocialismDocService.IndexSlug)
return RedirectPermanent("/socialism");
Doc = docService.GetDoc(slug);
if (Doc is null)
return NotFound();
CurrentSlug = slug;
AllDocs = docService.GetAllDocs();
var position = AllDocs.FindIndex(d => d.Slug == slug);
Previous = position > 0 ? AllDocs[position - 1] : null;
Next = position >= 0 && position < AllDocs.Count - 1 ? AllDocs[position + 1] : null;
return Page();
}
}
@@ -0,0 +1,65 @@
@page "/socialism"
@model JoshHeaps.Net.Pages.Socialism.IndexModel
@{
ViewData["Title"] = Model.Overview!.Title + " - JoshHeaps.Net";
Layout = "_Layout";
}
<div class="soc-progress" id="socProgress"></div>
<button class="soc-menu-toggle" id="socMenuToggle" aria-controls="socSidebar" aria-expanded="false">Contents</button>
<div class="soc-shell">
<partial name="_Nav" model="Model" />
<div class="soc-main">
<header class="soc-hero">
<h1 class="soc-hero-title">@Model.Overview.Title</h1>
@if (!string.IsNullOrEmpty(Model.Overview.Summary))
{
<p class="soc-hero-summary">@Model.Overview.Summary</p>
}
@if (Model.Chapters.Count > 0)
{
<a class="soc-hero-cta" href="/socialism/@Model.Chapters[0].Slug">Start reading &rarr;</a>
}
</header>
<article class="soc-content">
@Html.Raw(Model.Overview.HtmlContent)
</article>
@if (Model.Chapters.Count > 0)
{
<section class="soc-chapters" id="explore">
<h2 class="soc-chapters-title">Explore the full model</h2>
@foreach (var group in Model.Chapters.GroupBy(d => d.Section))
{
@if (!string.IsNullOrEmpty(group.Key))
{
<h3 class="soc-chapters-group">@group.Key</h3>
}
<div class="soc-card-grid">
@foreach (var doc in group)
{
<a class="soc-card" href="/socialism/@doc.Slug">
<span class="soc-card-title">@doc.Title</span>
@if (!string.IsNullOrEmpty(doc.Summary))
{
<span class="soc-card-summary">@doc.Summary</span>
}
<span class="soc-card-meta">@doc.ReadingMinutes min read</span>
</a>
}
</div>
}
</section>
}
</div>
<partial name="_Toc" model="Model.Overview.Headings" />
</div>
@section Styles {
<partial name="_Head" />
}
@@ -0,0 +1,24 @@
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Implementations;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace JoshHeaps.Net.Pages.Socialism;
public class IndexModel(ISocialismDocService docService) : SocialismPageModel
{
public SocialismDoc? Overview { get; private set; }
public List<SocialismDoc> Chapters { get; private set; } = [];
public IActionResult OnGet()
{
Overview = docService.GetDoc(SocialismDocService.IndexSlug);
if (Overview is null)
return NotFound();
CurrentSlug = SocialismDocService.IndexSlug;
AllDocs = docService.GetAllDocs();
Chapters = AllDocs.Where(d => d.Slug != SocialismDocService.IndexSlug).ToList();
return Page();
}
}
@@ -0,0 +1,21 @@
using JoshHeaps.Net.Models;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages.Socialism;
/// <summary>
/// Shared base for the /socialism pages. These pages are unlisted: nothing links to them
/// and search engines are told not to index them.
/// </summary>
public abstract class SocialismPageModel : PageModel
{
public List<SocialismDoc> AllDocs { get; protected set; } = [];
public string CurrentSlug { get; protected set; } = string.Empty;
public override void OnPageHandlerExecuting(PageHandlerExecutingContext context)
{
Response.Headers["X-Robots-Tag"] = "noindex, nofollow";
base.OnPageHandlerExecuting(context);
}
}
@@ -0,0 +1,4 @@
<meta name="robots" content="noindex, nofollow" />
<link href="/css/variables.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/socialism/socialism.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<script src="/js/socialism/socialism.js?v=@ViewData["cssVersion"]" defer></script>
+32
View File
@@ -0,0 +1,32 @@
@model SocialismPageModel
@{
var groups = Model.AllDocs
.Where(d => d.Slug != SocialismDocService.IndexSlug)
.GroupBy(d => d.Section);
}
<nav class="soc-sidebar" id="socSidebar" aria-label="Document navigation">
<a href="/socialism" class="soc-sidebar-home @(Model.CurrentSlug == SocialismDocService.IndexSlug ? "active" : "")">Overview</a>
<input type="search" class="soc-filter" id="socFilter" placeholder="Filter sections..." aria-label="Filter sections" />
@foreach (var group in groups)
{
<div class="soc-nav-group">
@if (!string.IsNullOrEmpty(group.Key))
{
<div class="soc-nav-group-title">@group.Key</div>
}
<ul>
@foreach (var doc in group)
{
<li>
<a href="/socialism/@doc.Slug"
class="soc-nav-link @(doc.Slug == Model.CurrentSlug ? "active" : "")"
data-filter-text="@doc.Title.ToLowerInvariant() @doc.Summary.ToLowerInvariant()">@doc.Title</a>
</li>
}
</ul>
</div>
}
</nav>
+16
View File
@@ -0,0 +1,16 @@
@model List<SocialismHeading>
@if (Model.Count > 1)
{
<aside class="soc-toc" aria-label="On this page">
<div class="soc-toc-title">On this page</div>
<ul>
@foreach (var heading in Model)
{
<li class="[email protected]">
<a href="#@heading.Id" data-toc-id="@heading.Id">@heading.Text</a>
</li>
}
</ul>
</aside>
}
@@ -0,0 +1,4 @@
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@using JoshHeaps.Net.Models
@using JoshHeaps.Net.Pages.Socialism
@using JoshHeaps.Net.Services.Implementations
+43
View File
@@ -1,6 +1,7 @@
using JoshHeaps.Net.Hubs;
using JoshHeaps.Net.Services.Implementations;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.StaticFiles;
var builder = WebApplication.CreateBuilder(args);
@@ -19,6 +20,7 @@ builder.Services.AddHttpClient("BlogApi", client =>
client.Timeout = TimeSpan.FromSeconds(10);
});
builder.Services.AddSingleton<IBlogService, BlogService>();
builder.Services.AddSingleton<ISocialismDocService, SocialismDocService>();
builder.Services.AddSingleton<IChessService, ChessService>();
builder.Services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
@@ -52,8 +54,49 @@ if (!app.Environment.IsDevelopment())
app.UseHttpsRedirection();
// One spelling of the casino URLs, because the Hold'em tool's offline support depends
// on it and nothing warns you when it breaks.
//
// Razor Pages routing is case-insensitive, so /Casino/TexasHoldem and
// /casino/texasholdem both serve the dealer tool and look identical. A service worker's
// scope is not: the spec matches it as a plain string prefix, so a worker scoped to
// "/casino/" does not control a page at "/Casino/TexasHoldem". Measured, that meant the
// capital-C spelling registered the worker, activated it, logged nothing — and then left
// the cache completely empty, because the page was never controlled and the precache
// handshake it waited on could not resolve. A dealer who bookmarked that URL had a tool
// that looked loaded and had nothing stored for the night the signal went.
//
// So one spelling wins and the other is redirected to it, permanently, before anything
// else in the pipeline gets a look.
//
// Deliberately scoped to /casino rather than applied site-wide. Every file under
// wwwroot/casino and wwwroot/images/casino is already lowercase, so lowercasing a path
// here can never miss a real file on a case-sensitive filesystem — a guarantee that does
// not hold for the rest of the site, and one that /chessHub in particular would break.
app.Use(async (context, next) =>
{
var path = context.Request.Path.Value;
if (path is not null
&& path.StartsWith("/casino", StringComparison.OrdinalIgnoreCase)
&& path.Any(char.IsUpper))
{
context.Response.Redirect(path.ToLowerInvariant() + context.Request.QueryString, permanent: true);
return;
}
await next();
});
// .webmanifest isn't in the default MIME map, and an unknown extension isn't served
// with the wrong type — it isn't served at all. Without this the Hold'em tool's
// manifest 404s, and a 404 manifest is exactly the silent failure that stops a browser
// ever offering "Add to Home Screen": nothing is logged and no prompt appears.
var contentTypes = new FileExtensionContentTypeProvider();
contentTypes.Mappings[".webmanifest"] = "application/manifest+json";
app.UseStaticFiles(new StaticFileOptions
{
ContentTypeProvider = contentTypes,
OnPrepareResponse = ctx =>
{
ctx.Context.Response.Headers.Append("Cache-Control", "no-cache, no-store, must-revalidate");
@@ -0,0 +1,178 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
using Markdig;
using Markdig.Renderers.Html;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
namespace JoshHeaps.Net.Services.Implementations;
/// <summary>
/// Serves the markdown documents in Content/Socialism. Files starting with an underscore
/// (the founding draft, the progress log) are private and never served.
/// </summary>
public partial class SocialismDocService : ISocialismDocService
{
public const string IndexSlug = "index";
private readonly string _contentDir;
private readonly ILogger<SocialismDocService> _logger;
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.Build();
public SocialismDocService(IWebHostEnvironment env, ILogger<SocialismDocService> logger)
{
_contentDir = Path.Combine(env.ContentRootPath, "Content", "Socialism");
_logger = logger;
}
public SocialismDoc? GetDoc(string slug)
{
if (!IsValidSlug(slug))
return null;
var path = Path.Combine(_contentDir, slug + ".md");
if (!File.Exists(path))
return null;
// Cache keyed on last-write time so edited documents show up without a restart.
var lastWrite = File.GetLastWriteTimeUtc(path);
if (_cache.TryGetValue(slug, out var entry) && entry.LastWrite == lastWrite)
return entry.Doc;
try
{
var doc = Parse(slug, File.ReadAllText(path));
_cache[slug] = new CacheEntry(doc, lastWrite);
return doc;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to render socialism doc {Slug}", slug);
return entry?.Doc;
}
}
public List<SocialismDoc> GetAllDocs()
{
if (!Directory.Exists(_contentDir))
return [];
return Directory.EnumerateFiles(_contentDir, "*.md")
.Select(Path.GetFileNameWithoutExtension)
.Where(slug => slug is not null && IsValidSlug(slug))
.Select(slug => GetDoc(slug!))
.OfType<SocialismDoc>()
.OrderBy(d => d.Slug == IndexSlug ? 0 : 1)
.ThenBy(d => d.Order)
.ThenBy(d => d.Title)
.ToList();
}
// Lowercase letters, digits and hyphens only: rules out private "_" files and path traversal.
private static bool IsValidSlug(string slug) => SlugRegex().IsMatch(slug);
private static SocialismDoc Parse(string slug, string raw)
{
var (meta, body) = SplitFrontMatter(raw);
var markdown = Markdown.Parse(body, Pipeline);
var headings = new List<SocialismHeading>();
string? firstH1 = null;
foreach (var heading in markdown.Descendants<HeadingBlock>())
{
var text = InlineText(heading.Inline);
if (heading.Level == 1)
{
firstH1 ??= text;
continue;
}
var id = heading.GetAttributes().Id;
if (heading.Level <= 3 && !string.IsNullOrEmpty(id))
headings.Add(new SocialismHeading(heading.Level, id, text));
}
foreach (var link in markdown.Descendants<LinkInline>())
{
if (!link.IsImage && link.Url is not null)
link.Url = RewriteLink(link.Url);
}
var words = WordRegex().Matches(body).Count;
return new SocialismDoc
{
Slug = slug,
Title = meta.GetValueOrDefault("title") ?? firstH1 ?? slug,
Summary = meta.GetValueOrDefault("summary") ?? string.Empty,
Section = meta.GetValueOrDefault("section") ?? string.Empty,
Order = int.TryParse(meta.GetValueOrDefault("order"), out var order) ? order : int.MaxValue,
HtmlContent = markdown.ToHtml(Pipeline),
Headings = headings,
ReadingMinutes = Math.Max(1, (int)Math.Round(words / 230.0))
};
}
// "economy.md#floor" -> "/socialism/economy#floor"; "index.md" -> "/socialism".
private static string RewriteLink(string url)
{
var match = RelativeDocLinkRegex().Match(url);
if (!match.Success)
return url;
var target = match.Groups["slug"].Value;
var anchor = match.Groups["anchor"].Value;
return (target == IndexSlug ? "/socialism" : $"/socialism/{target}") + anchor;
}
private static (Dictionary<string, string> Meta, string Body) SplitFrontMatter(string raw)
{
var meta = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var match = FrontMatterRegex().Match(raw);
if (!match.Success)
return (meta, raw);
foreach (var line in match.Groups["yaml"].Value.Split('\n'))
{
var idx = line.IndexOf(':');
if (idx <= 0)
continue;
meta[line[..idx].Trim()] = line[(idx + 1)..].Trim().Trim('"');
}
return (meta, raw[match.Length..]);
}
private static string InlineText(ContainerInline? inline)
{
if (inline is null)
return string.Empty;
return string.Concat(inline.Descendants<Inline>().Select(i => i switch
{
LiteralInline literal => literal.Content.ToString(),
CodeInline code => code.Content,
_ => string.Empty
}));
}
[GeneratedRegex("^[a-z0-9][a-z0-9-]*$")]
private static partial Regex SlugRegex();
[GeneratedRegex(@"^(?:\./)?(?<slug>[a-z0-9][a-z0-9-]*)\.md(?<anchor>#[^\s]*)?$")]
private static partial Regex RelativeDocLinkRegex();
[GeneratedRegex(@"\A---\r?\n(?<yaml>.*?)\r?\n---\r?\n", RegexOptions.Singleline)]
private static partial Regex FrontMatterRegex();
[GeneratedRegex(@"\b\w+\b")]
private static partial Regex WordRegex();
private record CacheEntry(SocialismDoc Doc, DateTime LastWrite);
}
@@ -0,0 +1,9 @@
using JoshHeaps.Net.Models;
namespace JoshHeaps.Net.Services.Interfaces;
public interface ISocialismDocService
{
SocialismDoc? GetDoc(string slug);
List<SocialismDoc> GetAllDocs();
}
@@ -0,0 +1,32 @@
{
"name": "Texas Hold'em Dealer",
"short_name": "Hold'em",
"description": "Seat your players, track the action, split the pot. Works with no signal.",
"start_url": "/casino/texasholdem",
"scope": "/casino/",
"display": "standalone",
"orientation": "any",
"background_color": "#141414",
"theme_color": "#141414",
"categories": ["utilities"],
"icons": [
{
"src": "/images/casino/holdem-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/images/casino/holdem-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/images/casino/holdem-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+125
View File
@@ -0,0 +1,125 @@
// Keeps the dealer tool working when the network doesn't.
//
// A home game is the worst network in the world: somebody's back room, one bar of
// signal, a router two floors away. The tool already keeps the whole night in
// localStorage, so the *table* survives anything — but the page didn't. Reload with no
// signal, or open it fresh when the wifi has dropped, and a dealer holding a phone in
// one hand and a deck in the other got a browser error page with nine players waiting.
//
// This is the one thing standing between "a website about poker" and "a tool you can
// rely on at a table", so it is worth the moving part.
//
// Scope is /casino/ rather than /casino/texasholdem/, because a scope has to be a
// prefix of the page it controls and "/casino/texasholdem" does not start with
// "/casino/texasholdem/". Anything in that scope this worker has not been told about
// goes straight to the network, so the other casino pages behave exactly as they did.
// Bumped whenever the shape of what is cached changes. The asset *version* rides in
// the URLs themselves (?v=1.0.8 from _Layout), so a normal release makes new keys
// without touching this.
const CACHE = "holdem-shell-v1";
// What this worker will keep. Anything else in scope — a form post, an API call, a
// page it was never told about — goes straight to the network and behaves exactly as
// it did before the worker existed.
const CACHEABLE = new Set(["script", "style", "image", "font", "manifest"]);
// The page hands over the exact list of URLs it was built from — see registerWorker()
// in main.js. Deriving it from the document instead of keeping a list here is the
// whole trick: a hand-written manifest of files drifts from the markup the first time
// somebody adds a script tag, and the failure mode is a tool that is offline-capable
// right up until the release that quietly broke it.
async function precache(urls) {
const cache = await caches.open(CACHE);
// Individually rather than cache.addAll, which rejects the whole batch if any one
// URL 404s — one stale entry in the list would leave the dealer with nothing.
await Promise.all(urls.map(async url => {
try {
const res = await fetch(url, { cache: "reload" });
if (res.ok) await cache.put(url, res);
} catch {
// Offline while installing. The runtime handler below picks these up on
// the next online visit.
}
}));
}
// No install handler, and deliberately no skipWaiting(). A new worker taking over a
// live page can hand it assets from a different release than the ones it started with,
// and this page loads seventeen separate scripts that expect to agree with each other.
// Waiting costs a dealer nothing — the update lands the next time they open the tool —
// and it cannot leave them with a half-swapped app in the middle of a hand.
self.addEventListener("activate", event => {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(keys
.filter(key => key.startsWith("holdem-shell-") && key !== CACHE)
.map(key => caches.delete(key)));
await self.clients.claim();
})());
});
self.addEventListener("message", event => {
if (event.data && event.data.type === "precache" && Array.isArray(event.data.urls)) {
event.waitUntil(precache(event.data.urls));
}
});
// Only GETs, and only same-origin. A cross-origin request has opaque responses that
// can't be inspected, and caching one would mean serving a failure forever.
function handles(request) {
if (request.method !== "GET") return false;
const url = new URL(request.url);
return url.origin === self.location.origin;
}
self.addEventListener("fetch", event => {
const { request } = event;
if (!handles(request)) return;
// The page itself: network first, so an online dealer always gets the current
// build, and the cached copy is there the moment they aren't.
if (request.mode === "navigate") {
event.respondWith((async () => {
try {
const fresh = await fetch(request);
if (fresh.ok) (await caches.open(CACHE)).put(request, fresh.clone());
return fresh;
} catch {
const cached = await caches.match(request, { ignoreSearch: true });
if (cached) return cached;
throw new Error("offline and this page was never cached");
}
})());
return;
}
// Assets: stale while revalidate. The cached copy answers immediately — which is
// the whole point, a dealer opening this between hands should not wait for a
// network — and a fresh copy is fetched in the background for next time.
//
// Cache-first would be the obvious choice, on the reasoning that these URLs carry
// ?v= from the layout and so a hit is always the right file. They don't: the
// version renders empty (see the note in TexasHoldem.cshtml), so every release
// reuses the same URLs. Cache-first against unversioned URLs is how a tool gets
// frozen on a build from three months ago with no way for anyone to tell. This
// strategy needs no version to be correct: worst case a dealer is one launch
// behind, which for a tool that runs entirely in the browser is nothing.
if (!CACHEABLE.has(request.destination)) return;
event.respondWith((async () => {
const cache = await caches.open(CACHE);
const cached = await cache.match(request);
const fresh = fetch(request).then(res => {
if (res.ok) cache.put(request, res.clone());
return res;
}).catch(() => null);
if (cached) return cached;
const res = await fresh;
if (res) return res;
throw new Error("offline and not cached: " + request.url);
})());
});
File diff suppressed because it is too large Load Diff
@@ -148,9 +148,13 @@ button,
color: #8f8f8f;
}
/* 37px tall, on the field a dealer types every amount into. The touch rule has been
44px everywhere else since the round that set it; this page never got measured for
it, because the audit only ran the target check when asked and nothing asked. */
.unevenAmount {
flex: 1;
min-width: 0;
min-height: 46px;
background: none;
border: none;
outline: none;
@@ -180,10 +184,12 @@ button,
color: #ffd6d6;
}
/* 30px across, and it deletes a player's entry — the one control on the row where a
near miss costs something. The amount field beside it gives up the 16px. */
.unevenRemoveBtn {
flex: none;
width: 30px;
height: 44px;
width: 46px;
height: 46px;
border: none;
background: none;
/* Was #6a6a6a — 3.66:1, the worst on the page, on a control that deletes a
@@ -0,0 +1,557 @@
*, *::before, *::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
scroll-padding-top: 1.5rem;
}
body {
margin: 0;
background-color: var(--color-bg);
color: var(--color-text);
font-family: var(--font-body);
line-height: 1.6;
}
/* ── Reading progress ── */
.soc-progress {
position: fixed;
top: 0;
left: 0;
height: 2px;
width: 0;
background: var(--color-accent);
z-index: 20;
transition: width 0.1s linear;
}
/* ── Three-column shell: nav | content | on-this-page ── */
.soc-shell {
display: grid;
grid-template-columns: 250px minmax(0, 760px) 220px;
justify-content: center;
gap: 3rem;
padding: 0 clamp(1rem, 3vw, 2rem);
}
.soc-main {
padding: 4rem 0 6rem;
min-width: 0;
}
/* ── Sidebar ── */
.soc-sidebar {
position: sticky;
top: 0;
align-self: start;
max-height: 100vh;
overflow-y: auto;
padding: 4rem 0 2rem;
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
}
.soc-sidebar-home {
display: block;
color: var(--color-heading-secondary);
font-weight: 600;
text-decoration: none;
margin-bottom: 1rem;
}
.soc-sidebar-home.active,
.soc-sidebar-home:hover {
color: var(--color-accent);
}
.soc-filter {
width: 100%;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 6px;
color: var(--color-heading-secondary);
padding: 0.45rem 0.65rem;
font: inherit;
font-size: 0.8rem;
margin-bottom: 1.25rem;
}
.soc-filter:focus {
outline: none;
border-color: var(--color-accent-border);
}
.soc-nav-group {
margin-bottom: 1.25rem;
}
.soc-nav-group-title {
color: var(--color-text-subtle);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 0.4rem;
}
.soc-sidebar ul {
list-style: none;
margin: 0;
padding: 0;
}
.soc-nav-link {
display: block;
color: var(--color-text);
text-decoration: none;
font-size: 0.85rem;
padding: 0.3rem 0 0.3rem 0.75rem;
border-left: 1px solid var(--color-border);
transition: color 0.2s, border-color 0.2s;
}
.soc-nav-link:hover {
color: var(--color-heading-secondary);
border-left-color: var(--color-border-hover);
}
.soc-nav-link.active {
color: var(--color-accent);
border-left-color: var(--color-accent);
}
.soc-nav-link.hidden {
display: none;
}
/* ── On this page ── */
.soc-toc {
position: sticky;
top: 0;
align-self: start;
max-height: 100vh;
overflow-y: auto;
padding: 4rem 0 2rem;
font-size: 0.8rem;
}
.soc-toc-title {
color: var(--color-text-subtle);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 0.6rem;
}
.soc-toc ul {
list-style: none;
margin: 0;
padding: 0;
}
.soc-toc a {
display: block;
color: var(--color-text-subtle);
text-decoration: none;
padding: 0.2rem 0;
transition: color 0.2s;
}
.soc-toc .soc-toc-level-3 a {
padding-left: 0.85rem;
}
.soc-toc a:hover {
color: var(--color-heading-secondary);
}
.soc-toc a.active {
color: var(--color-accent);
}
/* ── Landing hero ── */
.soc-hero {
margin-bottom: 3rem;
}
.soc-hero-title {
color: var(--color-heading);
font-size: clamp(2rem, 4vw, 2.75rem);
letter-spacing: -0.02em;
line-height: 1.15;
margin: 0 0 1rem;
}
.soc-hero-summary {
color: var(--color-heading-secondary);
font-size: 1.15rem;
line-height: 1.7;
margin: 0 0 1.5rem;
}
.soc-hero-cta {
display: inline-block;
color: var(--color-bg);
background: var(--color-accent);
font-weight: 600;
font-size: 0.9rem;
text-decoration: none;
padding: 0.55rem 1.1rem;
border-radius: 6px;
transition: background 0.2s;
}
.soc-hero-cta:hover {
background: var(--color-accent-hover);
}
/* ── Chapter cards ── */
.soc-chapters {
margin-top: 4rem;
}
.soc-chapters-title {
color: var(--color-heading);
font-size: 1.4rem;
margin: 0 0 1.25rem;
}
.soc-chapters-group {
color: var(--color-text-subtle);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 2rem 0 0.75rem;
}
.soc-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1rem;
}
.soc-card {
display: flex;
flex-direction: column;
gap: 0.4rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 1.1rem 1.2rem;
text-decoration: none;
transition: background 0.2s, border-color 0.2s, transform 0.2s;
}
.soc-card:hover {
background: var(--color-surface-hover);
border-color: var(--color-accent-border);
transform: translateY(-2px);
}
.soc-card-title {
color: var(--color-heading-secondary);
font-weight: 600;
}
.soc-card-summary {
color: var(--color-text);
font-size: 0.85rem;
line-height: 1.5;
flex: 1;
}
.soc-card-meta {
color: var(--color-text-subtle);
font-size: 0.75rem;
}
/* ── Document header ── */
.soc-breadcrumbs {
display: flex;
gap: 0.5rem;
font-size: 0.8rem;
color: var(--color-text-subtle);
}
.soc-breadcrumbs a {
color: var(--color-text-subtle);
text-decoration: none;
transition: color 0.2s;
}
.soc-breadcrumbs a:hover {
color: var(--color-heading-secondary);
}
.soc-doc-header {
margin: 1.5rem 0 2.5rem;
}
.soc-doc-title {
color: var(--color-heading);
font-size: clamp(1.75rem, 3vw, 2.25rem);
letter-spacing: -0.02em;
line-height: 1.25;
margin: 0 0 0.75rem;
}
.soc-doc-summary {
color: var(--color-heading-secondary);
font-size: 1.05rem;
margin: 0 0 0.5rem;
}
.soc-doc-meta {
color: var(--color-text-subtle);
font-size: 0.8rem;
}
/* ── Rendered markdown ── */
.soc-content {
line-height: 1.8;
}
.soc-content > h1:first-child {
display: none; /* the page header already shows the title */
}
.soc-content h2,
.soc-content h3,
.soc-content h4 {
position: relative;
color: var(--color-heading-secondary);
letter-spacing: -0.01em;
margin: 2.5rem 0 0.75rem;
}
.soc-content h2 {
font-size: 1.45rem;
padding-top: 1.5rem;
border-top: 1px solid var(--color-border);
}
.soc-content h3 { font-size: 1.15rem; }
.soc-anchor {
position: absolute;
left: -1.2em;
color: var(--color-text-subtle);
text-decoration: none;
opacity: 0;
transition: opacity 0.2s;
}
.soc-content h2:hover .soc-anchor,
.soc-content h3:hover .soc-anchor {
opacity: 1;
}
.soc-content p {
margin: 0 0 1.25rem;
}
.soc-content a {
color: var(--color-accent);
text-decoration: none;
}
.soc-content a:hover {
color: var(--color-accent-hover);
text-decoration: underline;
}
.soc-content strong {
color: var(--color-heading-secondary);
}
.soc-content ul,
.soc-content ol {
margin: 0 0 1.25rem 1.5rem;
padding: 0;
}
.soc-content li {
margin-bottom: 0.4rem;
}
.soc-content blockquote {
margin: 1.5rem 0;
padding: 0.75rem 1.25rem;
border-left: 3px solid var(--color-accent);
background: var(--color-surface);
color: var(--color-heading-secondary);
border-radius: 0 8px 8px 0;
}
.soc-content blockquote p:last-child {
margin-bottom: 0;
}
.soc-content hr {
border: none;
border-top: 1px solid var(--color-border);
margin: 2.5rem 0;
}
.soc-content code {
background: var(--color-surface-raised);
color: var(--color-heading-secondary);
padding: 0.15rem 0.4rem;
border-radius: 4px;
font-size: 0.85em;
font-family: var(--font-mono);
}
.soc-content table {
width: 100%;
border-collapse: collapse;
margin: 0 0 1.5rem;
font-size: 0.9rem;
display: block;
overflow-x: auto;
}
.soc-content th,
.soc-content td {
border: 1px solid var(--color-border);
padding: 0.5rem 0.75rem;
text-align: left;
vertical-align: top;
}
.soc-content th {
background: var(--color-surface-raised);
color: var(--color-heading-secondary);
}
.soc-content details {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 0.75rem 1rem;
margin: 0 0 1.25rem;
}
.soc-content summary {
cursor: pointer;
color: var(--color-heading-secondary);
font-weight: 600;
}
.soc-content .footnotes {
font-size: 0.85rem;
color: var(--color-text-subtle);
}
/* ── Pager ── */
.soc-pager {
display: flex;
gap: 1rem;
margin-top: 4rem;
}
.soc-pager-link {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.2rem;
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 0.9rem 1.1rem;
text-decoration: none;
transition: border-color 0.2s, background 0.2s;
}
.soc-pager-link.next {
text-align: right;
margin-left: auto;
}
.soc-pager-link:hover {
border-color: var(--color-accent-border);
background: var(--color-surface);
}
.soc-pager-label {
color: var(--color-text-subtle);
font-size: 0.75rem;
}
.soc-pager-title {
color: var(--color-heading-secondary);
font-weight: 600;
}
/* ── Mobile ── */
.soc-menu-toggle {
display: none;
}
@media (max-width: 1200px) {
.soc-shell {
grid-template-columns: 230px minmax(0, 760px);
}
.soc-toc {
display: none;
}
}
@media (max-width: 860px) {
.soc-shell {
grid-template-columns: minmax(0, 1fr);
}
.soc-main {
padding-top: 4.5rem;
}
.soc-menu-toggle {
display: block;
position: fixed;
top: 1rem;
left: 1rem;
z-index: 15;
background: var(--color-surface-raised);
backdrop-filter: blur(8px);
border: 1px solid var(--color-border);
border-radius: 6px;
color: var(--color-heading-secondary);
font: inherit;
font-size: 0.85rem;
padding: 0.4rem 0.8rem;
cursor: pointer;
}
.soc-sidebar {
position: fixed;
inset: 0 30% 0 0;
min-width: 260px;
max-height: none;
background: var(--color-bg);
border-right: 1px solid var(--color-border);
padding: 4.5rem 1.25rem 2rem;
z-index: 10;
transform: translateX(-100%);
transition: transform 0.25s ease;
}
.soc-sidebar.open {
transform: translateX(0);
}
.soc-pager {
flex-direction: column;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -32,11 +32,23 @@ const ActionBar = {
const publish = () => {
const visible = !host.hidden;
const height = visible ? host.getBoundingClientRect().height : 0;
const rail = this.isRail(host);
// On a phone held sideways the bar is a rail down the right-hand side, so it
// takes no height at all — and `--actionBarHeight` is what four other rules
// and the felt measurement all mean by "how much of the bottom is spoken
// for". Publishing the rail's height there would reserve the whole window.
const height = visible && !rail ? host.getBoundingClientRect().height : 0;
document.documentElement.style.setProperty("--actionBarHeight", `${Math.round(height)}px`);
this.publishFeltRoom(height);
// What is under the felt is the bar in one layout and the two links in the
// other. Either way it is the same question — how much of the bottom is
// already spoken for — so it is the same argument.
this.publishFeltRoom(rail ? this.roomBelowFelt() : height);
};
// Kept so anything that moves the table without touching the bar can ask for
// the felt to be measured again — the board strip appearing above it does
// exactly that, and the felt's height is worked out from where the table starts.
this.publishHeight = publish;
if (typeof ResizeObserver === "function") {
this.heightWatcher = new ResizeObserver(publish);
this.heightWatcher.observe(host);
@@ -46,6 +58,33 @@ const ActionBar = {
publish();
},
// Is the bar a rail down the side rather than a bar across the bottom?
//
// Asked of the stylesheet rather than answered again here. The layout is chosen by
// a media query — orientation, height and width together — and a second copy of
// that query written in JavaScript is a copy that drifts: the first thing anyone
// changes is the breakpoint, in one of the two places. So the CSS declares which
// layout it picked in a custom property and this reads the answer back.
isRail(host) {
if (!host || typeof getComputedStyle !== "function") return false;
return getComputedStyle(host).getPropertyValue("--actionBarLayout").trim() === "rail";
},
// What sits between the bottom of the felt and the bottom of the window when the
// bar is off to one side: the table hint and the row of links under it. Measured,
// because both come and go — the hint is empty mid-hand and the links wrap on the
// narrowest column.
// Taken as the distance from the bottom of the felt to the bottom of the section it
// sits in, which is a difference rather than a height — so it doesn't move when the
// felt's own height changes, and this can't chase itself around the screen.
roomBelowFelt() {
const wrap = document.getElementById("pokerTableWrap");
const section = wrap && wrap.parentElement;
if (!section) return 0;
const gap = section.getBoundingClientRect().bottom - wrap.getBoundingClientRect().bottom;
return Math.max(0, Math.round(gap));
},
// Half a seat, the gap left under the lowest one, and the smallest felt worth
// drawing. A seat is about 73px tall at every width — the puck is text-sized, not
// table-sized — so the overhang is a constant rather than something read back off
@@ -53,7 +92,14 @@ const ActionBar = {
// moving the seats, and getting a different answer on the next pass.
SEAT_OVERHANG_PX: 37,
FELT_GAP_PX: 8,
FELT_MIN_PX: 220,
// The floor is a guard against a measurement that has gone wrong — not a size the
// window has agreed to hand over. It used to be 220px, which a phone on its side
// hasn't got: at 844x390 there are 99px between the header and the bar, and holding
// out for 220 is how the bottom row of seats came to sit 26px inside the Fold
// button. A floor that puts seats behind the bar defeats the one thing the floor is
// for, so it drops to the smallest felt still worth drawing and the measurement
// wins above it.
FELT_FLOOR_PX: 96,
// How tall the felt can be and still leave the whole ring of seats above the bar.
//
@@ -66,14 +112,40 @@ const ActionBar = {
//
// The wrap's offset is taken in document space rather than viewport space, so
// scrolling the page doesn't resize the table under the dealer's hands.
feltRoomFor(windowHeight, barHeight, topInDocument) {
const room = windowHeight - barHeight - topInDocument -
this.SEAT_OVERHANG_PX - this.FELT_GAP_PX;
return Math.max(this.FELT_FLOOR_PX, Math.round(room));
},
publishFeltRoom(barHeight) {
const wrap = document.getElementById("pokerTableWrap");
if (!wrap) return;
// Taking the top before the height is set matters: the wrap sits below the
// header in normal flow, so its own height never moves its top and this can't
// chase itself around the screen.
const topInDocument = wrap.getBoundingClientRect().top + window.scrollY;
const room = window.innerHeight - barHeight - topInDocument -
this.SEAT_OVERHANG_PX - this.FELT_GAP_PX;
document.documentElement.style.setProperty(
"--feltRoomPx", `${Math.max(this.FELT_MIN_PX, Math.round(room))}px`);
const root = document.documentElement;
const was = root.style.getPropertyValue("--feltRoomPx");
const now = `${this.feltRoomFor(window.innerHeight, barHeight, topInDocument)}px`;
root.style.setProperty("--feltRoomPx", now);
// The felt has just changed size, so how much rim each seat gets has changed
// with it. Told rather than left to be noticed on the next render, which on a
// bar that grew mid-hand could be several actions away.
if (typeof Render === "undefined" || !Render.state) return;
if (Render.updateSeatDensity) Render.updateSeatDensity();
// And when it really did change size, the seats are laid out again rather than
// only re-measured.
//
// The seats follow a felt on their own — they are placed in percentages of it —
// but a bet chip anchored inside a seat is placed in pixels off that seat's
// centre, worked out against the felt as it was. Nothing re-ran that. Measured
// at 640x360 with five players: the chips were positioned for a felt 83px tall,
// painted on one of 60, and seat 3's $5 came to rest on seat 4's puck. The bar
// growing mid-hand does this too — a side pot appearing is 55px off the felt —
// so it is not only the board strip that could reach it.
if (now !== was && Render.renderSeats) Render.renderSeats();
},
// Anything to act on? A hand with nobody dealt in doesn't get a bar — and that
@@ -94,12 +166,19 @@ const ActionBar = {
if (!host) return;
host.innerHTML = "";
// A table with nobody at it used to hide this bar altogether, which meant the
// one moment a dealer has never used the tool before is the one moment it says
// nothing at all. What they got instead was a felt printing EMPTY ten times and
// a green Resolve Pot button that would resolve nothing.
//
// An empty table is a state with an obvious next step, so it says what it is.
if (!this.isRunning()) {
host.hidden = true;
document.body.classList.remove("hasActionBar");
document.documentElement.style.setProperty("--actionBarHeight", "0px");
host.hidden = false;
document.body.classList.add("hasActionBar");
host.appendChild(this.renderEmptyTable());
this.stopTankTimer();
this.tankSeatIndex = null;
this.announce();
return;
}
host.hidden = false;
@@ -123,12 +202,104 @@ const ActionBar = {
}
this.syncTankTimer();
this.announce();
},
// The empty table. One sentence and one button, in the place every other prompt in
// this tool appears, so a dealer who has never opened it before is looking at the
// next step rather than at ten chairs labelled EMPTY.
renderEmptyTable() {
const wrap = document.createElement("div");
wrap.className = "dealPrompt";
const line = document.createElement("p");
line.className = "dealPromptLine";
line.textContent = "Nobody's at the table yet.";
wrap.appendChild(line);
const buttons = document.createElement("div");
buttons.className = "dealPromptButtons";
const seat = document.createElement("button");
seat.type = "button";
seat.className = "actionBtn actionDeal";
seat.textContent = "Seat a player";
seat.addEventListener("click", () => this.hooks.seatFirstPlayer());
buttons.appendChild(seat);
wrap.appendChild(buttons);
return wrap;
},
// One sentence, for anyone running this by ear.
//
// The bar used to carry aria-live itself, and because render() rebuilds the whole
// thing every action, a screen reader read the lot out after every tap — street,
// pot, player count, who's short, the clock, the undo label and all three buttons.
// Nothing in that is wrong; it is just unusable at the speed a hand is dealt.
//
// What a dealer actually needs from the tool between taps is who is on the clock
// and what it costs them. That is what this says, into a small element that exists
// from page load and only ever has its text replaced.
//
// Guarded on the sentence itself rather than on the state, because render() is
// called for a great many reasons that don't change whose turn it is — resizing the
// window, opening a panel, editing a note — and a live region that repeats itself
// is read out again every time.
announce() {
const target = document.getElementById("actionAnnouncer");
if (!target) return;
const said = this.announcement();
if (said === this.lastAnnouncement) return;
this.lastAnnouncement = said;
target.textContent = said;
},
announcement() {
const state = this.state;
if (!this.isRunning()) return "Nobody's at the table yet.";
if (!state.bettingOpen) {
const preview = Betting.blindPreview(state);
return preview ? "Ready to deal." : "";
}
if (Betting.isHandOver(state)) return "Hand over. Resolve the pot.";
const index = state.actingSeatIndex;
if (index === null || index === undefined) {
return `${Betting.streetLabel(state.street)} betting is done.`;
}
const player = state.seats[index];
const name = HoldemState.displayName(player, index);
const owed = Betting.toCall(state, player);
const cost = owed > 0 ? `${Money.format(owed)} to call` : "checked to them";
return `${name} to act, ${cost}. Pot ${Money.format(Betting.potSoFar(state))}.`;
},
// Pure, so the wording can be checked without a page to hang it on — the same reason
// Render.panelGlances is. A table that has never dealt is on hand one, not hand
// zero and not hand undefined, and that fallback is the whole of what there is to
// get wrong here.
handLabel(state) {
return `#${(state && state.handNumber) || 1}`;
},
renderStatus() {
const row = document.createElement("div");
row.className = "actionStatus";
// Which hand this is. It used to be a gold pill beside the page title, and it
// was the wrong fact in the wrong place: a counter that changes once a hand,
// read after the fact, holding a line of a 390px phone's header all night. It
// belongs with the street, the pot and the player count — the other three
// things that say what hand you are in — and here it costs nothing, because
// this row was already drawn.
const hand = document.createElement("span");
hand.className = "actionHand";
hand.textContent = this.handLabel(this.state);
row.appendChild(hand);
const street = document.createElement("span");
street.className = "actionStreet";
street.textContent = Betting.streetLabel(this.state.street);
@@ -156,8 +327,12 @@ const ActionBar = {
if (Betting.isRunningOut(this.state) && !Betting.isHandOver(this.state)) {
const runout = document.createElement("span");
runout.className = "actionRunout";
const stillIn = Betting.liveSeats(this.state).length;
runout.textContent = `All-in — running out the board (${stillIn} players)`;
// It used to end "(12 players)", which is the number the chip immediately to
// its left already says, in the same row, at the same moment. Saying it
// twice in one breath reads like two different figures being quoted, and on
// a 360px phone it cost the sentence a second line — which the felt paid
// for, on the one table shape that has twelve seats to fit.
runout.textContent = "All-in — running out the board";
row.appendChild(runout);
}
@@ -175,6 +350,32 @@ const ActionBar = {
tank.title = "How long this player has been thinking";
tank.textContent = "0:00";
row.appendChild(tank);
// Rendered every time and shown by the tick once the wait is long enough
// to be worth it, so a clock never costs a re-render at the moment the
// dealer reaches for it — and never occupies the bar in the ordinary case
// where nobody is holding anybody up.
const clock = document.createElement("button");
clock.type = "button";
clock.id = "actionClockBtn";
clock.className = "actionClockBtn";
// Once the count is over there is nothing to stop: the ruling has been
// made, and the dealer either carries it out or takes an ordinary action,
// both of which end the clock themselves. Leaving the button there also
// cost real room — at 640x360 its 94px pushed the undo control onto a
// second line, and the taller bar covered the two bottom seats.
const expiredNow = this.clockRunning() && this.clockLeft() === 0;
clock.hidden = !this.clockRunning() || expiredNow;
if (this.clockRunning()) {
clock.textContent = "Stop the clock";
clock.title = "Put the decision back in the player's own time";
clock.addEventListener("click", () => { this.stopClock(); this.render(); });
} else {
clock.textContent = "Call the clock";
clock.title = `${this.CLOCK_THINK_S} seconds, then a ${this.CLOCK_COUNTDOWN_S}-second countdown`;
clock.addEventListener("click", () => this.callClock());
}
row.appendChild(clock);
}
// A standing control rather than a toast per action: a dealer taps this bar
@@ -186,7 +387,17 @@ const ActionBar = {
undo.type = "button";
undo.className = "actionUndo";
undo.title = `Undo ${undoLabel}`;
undo.textContent = `↶ ${undoLabel}`;
// The label goes in its own element rather than straight onto the button.
// The pill is a fixed width — it names the last action, so its text changes
// on every tap, and a control that resizes under the dealer's thumb reads
// as the interface twitching. Fixed width means long labels have to
// ellipsize, and only a real element can: as a bare text node the label
// truncated *through* the keycap below it, which then sat squashed against
// the cut-off text looking like a rendering fault.
const label = document.createElement("span");
label.className = "actionUndoLabel";
label.textContent = `↶ ${undoLabel}`;
undo.appendChild(label);
// U undoes, and nothing said so — this control was the only place a dealer
// could have learned it, and it was the one action bar control without its
// key printed on it.
@@ -214,6 +425,74 @@ const ActionBar = {
NUDGE_AFTER_S: 30,
SLOW_AFTER_S: 60,
// ---- Calling the clock ----
//
// The stopwatch above says how long somebody has been thinking. This is what a
// dealer does about it, and it is a procedure with a shape: a player asks for a
// clock, the dealer grants it, the player gets a set time and then a countdown,
// and if they still haven't acted the hand is decided for them — dead if they were
// facing a bet, checked if they weren't.
//
// The tool knew none of that. A dealer running it had to count in their head while
// the table argued about whether it had been thirty seconds, which is the moment
// the count needs to be somewhere everybody can see.
//
// Thirty seconds and then a ten-second countdown is the common form of the rule and
// what the TDA writes down. The countdown is the part the table hears out loud, so
// it is marked separately rather than being ten more seconds of the same.
CLOCK_THINK_S: 30,
CLOCK_COUNTDOWN_S: 10,
clockSeatIndex: null,
clockStartedAt: null,
clockRunning() {
return this.clockSeatIndex !== null &&
this.clockSeatIndex === this.state.actingSeatIndex &&
this.clockStartedAt !== null;
},
// Seconds left before the hand is decided, or null when no clock is running.
// Negative is impossible: it stops at zero and stays there until the dealer acts.
clockLeft(now = Date.now()) {
if (!this.clockRunning()) return null;
const total = this.CLOCK_THINK_S + this.CLOCK_COUNTDOWN_S;
return Math.max(0, total - Math.floor((now - this.clockStartedAt) / 1000));
},
// What happens when it reaches zero, in the words the dealer says out loud.
// Facing a bet, the hand is dead. Facing none, it is checked — a player who could
// have checked for free is not folded for being slow.
clockRuling() {
const index = this.state.actingSeatIndex;
if (index === null || index === undefined) return null;
const player = this.state.seats[index];
const owed = Betting.toCall(this.state, player);
const who = HoldemState.displayName(player, index);
return owed > 0
? { index, who, verb: "fold", label: `Time — ${who}'s hand is dead`, say: `${who} is facing ${Money.format(owed)}, so the hand is dead.` }
: { index, who, verb: "check", label: `Time — check for ${who}`, say: `${who} had nothing to call, so the hand is checked.` };
},
callClock() {
const index = this.state.actingSeatIndex;
if (index === null || index === undefined) return;
this.clockSeatIndex = index;
this.clockStartedAt = Date.now();
this.clockExpiredShown = false;
this.render();
},
stopClock() {
this.clockSeatIndex = null;
this.clockStartedAt = null;
this.clockExpiredShown = false;
},
// Whether the bar has already been rebuilt into its "time is up" shape, so the
// one-second tick doesn't rebuild it again every second afterwards.
clockExpiredShown: false,
tankSeatIndex: null,
tankStartedAt: null,
tankInterval: null,
@@ -243,6 +522,11 @@ const ActionBar = {
if (this.tankSeatIndex !== null) this.tankUnknownFor = null;
this.tankSeatIndex = index;
this.tankStartedAt = Date.now();
// A clock belongs to one player's decision. The moment the action moves —
// because they acted, or because the dealer took something back — it is
// over, and leaving it running would put the next player on a countdown
// nobody called on them.
this.stopClock();
}
if (!this.tankInterval) {
@@ -264,6 +548,36 @@ const ActionBar = {
const el = document.getElementById("actionTank");
if (!el || this.tankStartedAt === null) return;
// A clock takes the readout over: the number the table is waiting on is no
// longer how long this has taken but how long is left. At zero the bar has to
// change shape — a ruling and the button that carries it out — so that one
// gets a full render, once, rather than being painted in by the tick.
const left = this.clockLeft();
if (left !== null) {
if (left === 0 && !this.clockExpiredShown) {
this.clockExpiredShown = true;
this.render();
return;
}
el.textContent = left <= this.CLOCK_COUNTDOWN_S ? `${left}` : this.formatTank(left);
el.title = "Time left before the hand is decided";
el.classList.toggle("tankNudge", left > this.CLOCK_COUNTDOWN_S);
el.classList.toggle("tankSlow", left <= this.CLOCK_COUNTDOWN_S);
return;
}
// The button that starts a clock is offered once somebody has been thinking
// long enough for the table to start asking — which is the same threshold the
// stopwatch already marks. Before that it is a control nobody needs, and this
// bar is docked over the seats.
const nudge = document.getElementById("actionClockBtn");
if (nudge && !this.clockRunning()) {
const waited = Math.floor((Date.now() - this.tankStartedAt) / 1000);
nudge.hidden = this.tankUnknownFor === this.tankSeatIndex
? false
: waited < this.NUDGE_AFTER_S;
}
if (this.tankUnknownFor !== null && this.tankSeatIndex === this.tankUnknownFor) {
el.textContent = "—";
el.title = "The action was already on this seat when the page loaded, so there's nothing to time from yet. The clock starts when the action next moves.";
@@ -315,10 +629,20 @@ const ActionBar = {
this.state.seats.map((player, index) => [player.id, HoldemState.displayName(player, index)])
);
// Four is what the bar can hold without taking the felt with it — see
// buildPotBreakdown for the measurements, and uiAudit's fixedFurniture check
// for the ceiling this is keeping the bar under.
//
// Two, once the run-out has started, for the reason spelled out over there: the
// list is decision support, and during a run-out there is no decision left to
// support. The felt gets the difference, on the one table shape that has the
// most seats to fit into it.
return Render.buildPotBreakdown(
PotCalculator.layers(contributions),
PotCalculator.uncalledReturns(contributions),
id => nameById.get(id)
id => nameById.get(id),
null,
{ maxLayers: 4, summarise: Betting.isRunningOut(this.state) }
);
},
@@ -373,6 +697,19 @@ const ActionBar = {
// recorded so it can be taken back. A misclick on Fold is the one mistake in this
// tool a dealer can't talk their way out of.
commit(label, mutate) {
// A toast is news about the last thing that happened, and this is the next
// thing: whatever it says is now out of date. Two reasons it goes rather than
// waiting out its eight seconds. It lies — "Next hand — button on seat 2.
// [Undo]" reads as an offer to move the button back, but the snapshot behind
// that Undo predates the hand you have since dealt, so taking it would throw
// the hand away without saying so. And it is in the way: measured across the
// sweep, a toast left up over a dealt table covered seats, the clock button
// or the bar's own Undo on thirty of the small screens it was checked at,
// because there is nowhere on a 360x640 phone for a 560px strip to go. The
// bar's own Undo control takes over from here, and it names the step it
// would actually take back.
if (typeof Toast !== "undefined" && Toast.dismiss) Toast.dismiss();
this.hooks.beginAction(label);
mutate();
this.hooks.save(this.state);
@@ -386,6 +723,36 @@ const ActionBar = {
const stack = Betting.stackOf(this.state, player);
const who = HoldemState.displayName(player, index);
// Time is up. The ruling goes on the bar in the words the dealer says out loud,
// with the one action that carries it out — and the ordinary buttons stay
// underneath, because a player who speaks up as the count ends is still acting
// and the dealer is the one who decides whether it was in time.
const expired = this.clockLeft() === 0 ? this.clockRuling() : null;
const wrap = document.createElement("div");
if (expired) {
const line = document.createElement("p");
line.className = "clockExpired";
// The ruling in one sentence. `label` is the same call in three words for
// the button beside it; saying both left the line doubling back on itself
// — "Time — Dee's hand is dead — Dee is facing $2, so the hand is dead."
line.textContent = `Time — ${expired.say}`;
wrap.appendChild(line);
const enforce = document.createElement("div");
enforce.className = "actionButtons";
enforce.appendChild(this.button(
expired.verb === "fold" ? `Kill ${expired.who}'s hand` : `Check for ${expired.who}`,
"actionEnforceClock",
() => this.commit(
expired.verb === "fold" ? `${expired.who}'s hand killed on the clock` : `${expired.who}'s check on the clock`,
() => {
this.stopClock();
if (expired.verb === "fold") Betting.fold(this.state, index);
else Betting.check(this.state, index);
})));
wrap.appendChild(enforce);
}
const row = document.createElement("div");
row.className = "actionButtons";
@@ -412,7 +779,9 @@ const ActionBar = {
}));
}
return row;
if (!expired) return row;
wrap.appendChild(row);
return wrap;
},
// Quick amounts first, keypad second — a dealer calling "raise to sixty" wants a
@@ -577,6 +946,43 @@ const ActionBar = {
// by itself the instant the previous hand ended, which meant blinds were committed
// while people were still sitting down — and the blind badges would drift away from
// the money already posted as seats changed. Now the dealer says when.
// ", Dee straddles $4" — read out as part of what goes in before the cards, because
// that is what it is. Two of them read as "Dee straddles $4, Eve $8": the second
// name doesn't need the verb again, and the bar has no room for it.
straddleClause(preview) {
const straddles = preview.straddles || [];
if (straddles.length === 0) return "";
const name = i => HoldemState.displayName(this.state.seats[i], i);
const [first, ...rest] = straddles;
return `, ${name(first.index)} straddles ${Money.format(first.amount)}` +
rest.map(s => `, ${name(s.index)} ${Money.format(s.amount)}`).join("");
},
// Turning it on, doubling it, and calling it off. Heads-up there is no seat left of
// the big blind to straddle from, so nothing is offered rather than a button that
// would do nothing.
straddleButtons(preview) {
const on = (preview.straddles || []).length;
const max = preview.maxStraddles || 0;
if (max <= 0) return [];
const buttons = [];
const nextAmount = Money.format(this.state.bigBlind * Math.pow(2, on + 1));
if (on < max) {
buttons.push(this.button(
on === 0 ? `Straddle ${nextAmount}` : `Re-straddle ${nextAmount}`,
"actionStraddle",
() => this.commit(on === 0 ? "the straddle" : "the re-straddle",
() => { this.state.straddleCount = on + 1; })));
}
if (on > 0) {
buttons.push(this.button("No straddle", "actionStraddle actionStraddleOff",
() => this.commit("calling the straddle off",
() => { this.state.straddleCount = 0; })));
}
return buttons;
},
renderDealPrompt() {
const wrap = document.createElement("div");
wrap.className = "dealPrompt";
@@ -631,7 +1037,7 @@ const ActionBar = {
if (!field) return;
const panel = field.closest("details");
if (panel) panel.open = true;
field.scrollIntoView({ block: "center", behavior: "smooth" });
Render.bringIntoView(field, { block: "center" });
field.focus();
field.select();
}));
@@ -674,11 +1080,14 @@ const ActionBar = {
// A dead small blind is a hand with only one blind in it. Saying "nobody posts
// the small blind" out loud is the whole point — a dealer who reads a preview
// with a blind missing and isn't told why will assume the tool has lost track.
line.textContent = preview.deadSmallBlind
const blinds = document.createElement("span");
blinds.textContent = (preview.deadSmallBlind
? `No small blind this hand, ${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}`
: `${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` +
`${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` +
`${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`;
`${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}`) +
this.straddleClause(preview) +
`${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`;
line.appendChild(blinds);
wrap.appendChild(line);
// Why a position is missing. Both of these are the dead button rule doing its
@@ -752,8 +1161,18 @@ const ActionBar = {
}
const row = document.createElement("div");
row.className = "actionButtons";
row.className = "actionButtons dealPromptButtons";
// Beside the deal button rather than on a line of its own. Measured at 390x844:
// its own line cost 44px of felt on every deal prompt, straddle or no straddle,
// and this row is already there and already 44px tall. It reads in the order it
// happens, too — say the straddle, then deal.
this.straddleButtons(preview).forEach(button => row.appendChild(button));
row.appendChild(this.button("Post blinds & deal", "actionDeal", () => {
// The first hand is the moment this stops being a setup screen and starts
// being a night somebody could lose. It is also a tap, which is what every
// browser wants the "keep my data" request to come from — see
// requestDurable() in state.js. Harmless and instant after the first time.
if (this.hooks.keepStorage) this.hooks.keepStorage();
this.commit("dealing the hand", () => Betting.beginHand(this.state));
}));
wrap.appendChild(row);
@@ -783,6 +1202,20 @@ const ActionBar = {
return line;
},
// The cards come over before anybody is paid.
//
// Same shape as the street button above: when the dealer has asked to be asked, the
// moment they press this is the moment they are holding the hands, so the sheet
// opens here rather than leaving them to find the seat editor for each player with
// the pot already on the table. Off by default, and unchanged when it's off.
toShowdown() {
if (this.state.trackBoard && typeof Render !== "undefined") {
Render.openShowdownPicker(() => this.hooks.resolvePot());
return;
}
this.hooks.resolvePot();
},
renderStreetDone() {
const wrap = document.createElement("div");
@@ -798,10 +1231,14 @@ const ActionBar = {
row.className = "actionButtons";
row.appendChild(this.button(label, "actionAdvance", () => {
if (isRiver) {
this.hooks.resolvePot();
this.toShowdown();
return;
}
this.commit(`dealing the ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase()}`, () => Betting.closeStreet(this.state));
// The cards that just came off the deck. Asked for here rather than left
// for the dealer to remember, because the moment they are holding them is
// the only moment they know what they are without turning the board round.
if (this.state.trackBoard && typeof Render !== "undefined") Render.openBoardPicker();
}));
wrap.appendChild(row);
@@ -826,7 +1263,7 @@ const ActionBar = {
row.appendChild(this.button(
winner ? `Award pot to ${winner}` : "Resolve pot",
"actionAdvance",
() => this.hooks.resolvePot()
() => this.toShowdown()
));
wrap.appendChild(row);
return wrap;
@@ -13,6 +13,15 @@
const Betting = {
STREETS: ["preflop", "flop", "turn", "river"],
// How many community cards are face up once a street has been dealt. The flop is
// three at once and everything after it is one at a time, which is why this is a
// table rather than an index.
BOARD_CARDS: { preflop: 0, flop: 3, turn: 4, river: 5 },
boardCardsFor(street) {
return this.BOARD_CARDS[street] ?? 0;
},
streetLabel(street) {
return { preflop: "Pre-flop", flop: "Flop", turn: "Turn", river: "River" }[street] || "Pre-flop";
},
@@ -93,9 +102,60 @@ const Betting = {
satOut,
smallBlind: this.round(state.smallBlind || 0),
bigBlind: this.round(state.bigBlind || 0),
// What the straddles will be if the hand is dealt as it stands, and how
// many more this table could take.
straddles: this.straddleSeats(state),
maxStraddles: this.maxStraddles(state),
};
},
// ---- Straddles ----
//
// A straddle is a blind raise put out before the cards, by the player left of the
// big blind, for twice it. It buys the last word before the flop: everyone else
// acts first, and the straddler still has the option when it comes back round even
// if nobody raised. Re-straddles carry on left, each doubling again.
//
// Home games run them constantly and a dealer has to get three things right at
// once — the amount, who is first to act, and what the minimum raise becomes. The
// tool used to do none of them, so a dealer's only way through was to deal, have
// the straddler "raise" to $4, and hand the action to the wrong player.
// How many straddles this table could take. Everyone but the two blinds can put
// one out; heads-up that is nobody, which is the right answer — the button is
// already the small blind and there is no seat left of the big blind to straddle
// from.
maxStraddles(state) {
return Math.max(0, this.liveSeats(this.tableAsDealt(state)).length - 2);
},
// Who straddles and for how much, walking left from the big blind and doubling.
// Asked of the table as it will be dealt, so a seat about to be sat out for having
// no chips is never handed a straddle it can't post.
straddleSeats(state, count = state.straddleCount) {
const wanted = Math.min(Math.max(0, Number(count) || 0), this.maxStraddles(state));
if (wanted <= 0) return [];
const dealt = this.tableAsDealt(state);
const buttonIndex = HoldemState.buttonSeatForDeal(dealt);
const asDealt = buttonIndex === dealt.dealerSeatIndex
? dealt
: { ...dealt, dealerSeatIndex: buttonIndex };
const { bbIndex } = HoldemState.getBlindSeats(asDealt);
if (bbIndex === null) return [];
const straddles = [];
let index = bbIndex;
let amount = this.round((Number(state.bigBlind) || 0) * 2);
for (let n = 0; n < wanted; n++) {
index = this.nextToAct(asDealt, index);
if (index === null || index === bbIndex) break;
straddles.push({ index, amount });
amount = this.round(amount * 2);
}
return straddles;
},
// Does handing this seat chips also put them back in the game?
//
// At a real table those are one event, and here they were two: a busted player got
@@ -408,6 +468,116 @@ const Betting = {
return (state.street || "preflop") === "preflop" && state.seats.every(p => !p.hasActed);
},
// Are the blinds actually down? Moving the button or naming a blind means two
// completely different things depending on the answer, and `onlyBlindsIn` alone
// cannot tell them apart: a table sitting in setup, with no cards out and nothing
// committed, satisfies it just as well as a hand that has only just been dealt.
//
// With a hand open, the blinds are chips on the felt. Re-aiming them has to take
// those chips back and post them again from the new positions, or the badges and
// the money disagree.
//
// Before the deal it is a marker being slid round a table with nothing in the
// middle. Re-posting there would *deal the hand* — betting opened, blinds taken off
// two stacks, action on the seat after the big blind — which is not what a dealer
// means by "she's the big blind this one". Setting the positions is how a dealer
// gets ready to deal; the cards come out when they press the button, and not a
// moment before.
blindsArePosted(state) {
return !!state.bettingOpen && this.onlyBlindsIn(state);
},
// Chips the pot has been priced with but nobody has actually paid.
//
// Two ways a contribution gets into this tool. Tapping the action moves chips: the
// bet leaves the stack, sits in front of the player, and is swept into their hand
// total at the end of the street. The other way is the "without live betting" panel
// — a base pot amount, or chips counted out on a seat — where the dealer is
// recording a hand they are watching rather than driving, and nothing ever leaves
// anybody's stack.
//
// The pot wizard prices both the same way, and awarding it credits the winner. So
// in that second mode the table simply grew: measured at a three-handed table with
// a $50 base pot, paying the pot took the chips on the table from $600 to $750 and
// left the bank check blaming a rebuy that never happened.
//
// So before the pot is pushed, whatever was assumed rather than taken comes off the
// stacks that assumed it. Chips already committed or swept are left alone; they
// have been paid once already, and taking them twice is the same bug the other way
// round.
collectAssumedContributions(state) {
const taken = [];
state.seats.forEach((player, index) => {
const priced = HoldemState.computeContribution(player, state.chipLegend, state.basePotAmount);
const alreadyPaid = this.round(this.committed(player) + (Number(player.amountOverride) || 0));
const owed = this.round(priced - alreadyPaid);
if (owed <= this.EPSILON_CHIPS) return;
// Never more than they are holding. A seat priced above its own stack is a
// dealer's typo or a player who is short, and inventing the difference is
// what this whole function exists to stop.
const moved = this.round(Math.min(owed, this.stackOf(state, player)));
if (moved <= this.EPSILON_CHIPS) return;
player.stack = this.round(this.stackOf(state, player) - moved);
// Recorded where a swept bet lives, so the money is in exactly the place
// the rest of the tool looks for chips that are in the middle this hand.
player.amountOverride = this.round((Number(player.amountOverride) || 0) + moved);
taken.push({ index, name: HoldemState.displayName(player, index), amount: moved });
});
return { taken, total: this.round(taken.reduce((sum, row) => sum + row.amount, 0)) };
},
// A misdeal: the hand never happened, so every chip goes back where it came from.
//
// The rulings sheet in this tool has told dealers to redeal since the day it was
// written — first card to the wrong seat, a seat entitled to a hand dealt out, one
// of the first two cards exposed — and the tool had no way to do it. The only
// control that ended a live hand was Next Hand, which says out loud that it takes
// the money off the table without paying anybody. That is right for an abandoned
// pot and wrong for a misdeal, where the blinds and any calls belong to the people
// who put them in.
//
// Undo covers the first second of it — "↶ dealing the hand" is one tap — but only
// while the action history is still there. It lives in memory, so a dealer who
// refreshes the page mid-hand, or who is three calls in, has nothing.
//
// What is deliberately kept: the button, the blinds, the straddle and anyone
// posting in. A misdeal is the *same* hand dealt again, not the next one.
takeHandBack(state) {
const returned = [];
state.seats.forEach((player, index) => {
const back = this.round(this.committed(player) + (Number(player.amountOverride) || 0));
if (back > 0) {
player.stack = this.round(this.stackOf(state, player) + back);
returned.push({ index, name: HoldemState.displayName(player, index), amount: back });
}
player.committed = 0;
player.amountOverride = null;
player.hasActed = false;
player.folded = false;
player.cards = ["", ""];
});
state.bettingOpen = false;
state.street = "preflop";
state.currentBet = 0;
state.lastRaiseSize = 0;
state.actingSeatIndex = null;
state.streetAggressorIndex = null;
state.handAggressorIndex = null;
HoldemState.clearBoard(state);
// Nothing was posted, as far as the next rotation is concerned: the hand this
// would have counted as is being dealt again from the same button.
state.postedBlinds = null;
state.postedStraddles = [];
return {
returned,
total: this.round(returned.reduce((sum, row) => sum + row.amount, 0)),
};
},
// Pulls the blinds back off the table and posts them again from wherever the
// button and the blind assignment now say they belong. Refunding first is what
// keeps the chip count straight when the button gets passed round three or four
@@ -433,6 +603,10 @@ const Betting = {
state.street = "preflop";
state.currentBet = 0;
state.lastRaiseSize = 0;
// A fresh deal starts on an empty felt. Dealing without moving the button —
// after a misdeal, or a table set up and dealt twice — would otherwise leave
// the last hand's board sitting there being ranked against the new cards.
HoldemState.clearBoard(state);
// Posting a blind isn't aggression — a hand limped and checked all the way
// down has no aggressor at all, which is exactly the case the showdown rule
// sends to the seat left of the button.
@@ -475,6 +649,7 @@ const Betting = {
state.currentBet = 0;
state.actingSeatIndex = null;
state.postedBlinds = null;
state.postedStraddles = [];
return;
}
@@ -505,17 +680,35 @@ const Betting = {
// from one who has just walked up and had the blinds go past them.
state.dealtLastHand = state.seats.filter(p => p.inHand).map(p => p.id);
state.currentBet = this.round(Math.max(
sbIndex === null ? 0 : this.committed(state.seats[sbIndex]),
this.committed(state.seats[bbIndex])));
state.lastRaiseSize = bb || 0;
// Straddles go out after the blinds and before the cards, each one left of the
// last. Posted here rather than left to the dealer to fake as a raise, because
// a raise from that seat would hand the action to the wrong player.
const straddles = this.straddleSeats(state);
straddles.forEach(({ index, amount }) => this.putIn(state, index, amount));
state.postedStraddles = straddles.map(({ index }) => index);
state.currentBet = this.round(state.seats.reduce(
(most, player) => Math.max(most, this.committed(player)), 0));
// A straddle is a blind raise, so what has to be matched to raise again is the
// straddle itself, not the big blind underneath it: $2 blinds with a $4
// straddle make the next raise $8, the same way the $2 blind makes it $4.
// Left alone, the minimum would come out at $6 and the dealer would be talked
// out of it by every player at the table.
state.lastRaiseSize = straddles.length > 0 ? state.currentBet : (bb || 0);
// Heads-up the button is the small blind and acts first before the flop; with
// three or more it's the seat after the big blind. Either way, a blind big
// enough to put that player all-in leaves them nothing to decide.
//
// A straddle moves that seat along: first to act is left of the last straddler,
// and the straddler keeps the option, which falls out of nobody having acted
// yet.
const lastPosted = straddles.length > 0
? straddles[straddles.length - 1].index
: bbIndex;
state.actingSeatIndex = headsUp
? (this.stackOf(state, state.seats[sbIndex]) > this.EPSILON_CHIPS ? sbIndex : this.nextToAct(state, sbIndex))
: this.nextToAct(state, bbIndex);
: this.nextToAct(state, lastPosted);
},
// What a pot-sized raise actually is, as a "raise to" total.
@@ -16,75 +16,147 @@ const CardPicker = {
state: null,
hooks: null,
// Which set of slots is being filled: a seat's two hole cards, or the five
// community cards. The grid, the duplicate check and the tap-to-fill behaviour are
// the same job either way — what changes is where the card is written and how many
// slots there are to walk along.
target: null,
seatIndex: null,
cardIndex: 0,
open(state, hooks, seatIndex, cardIndex) {
this.state = state;
this.hooks = hooks;
this.target = "seat";
this.seatIndex = seatIndex;
this.cardIndex = cardIndex;
this.render();
document.getElementById("cardPickerOverlay").hidden = false;
},
close() {
document.getElementById("cardPickerOverlay").hidden = true;
// The board. `limit` is how many of the five are actually out — three on the flop,
// four on the turn — so the sheet never offers a slot for a card the dealer has not
// turned over yet.
openBoard(state, hooks, cardIndex, limit = 5) {
this.state = state;
this.hooks = hooks;
this.target = "board";
this.seatIndex = null;
this.boardLimit = Math.max(1, Math.min(5, limit));
this.cardIndex = Math.min(cardIndex, this.boardLimit - 1);
this.render();
document.getElementById("cardPickerOverlay").hidden = false;
},
// Every card sitting in front of anyone else. The slot being edited is excluded,
// so re-picking the card that's already there isn't treated as a duplicate.
onBoard() {
return this.target === "board";
},
// The slots the sheet is filling, whichever set that is.
slotValues() {
return this.onBoard()
? (this.state.board || []).slice(0, this.boardLimit)
: this.state.seats[this.seatIndex].cards;
},
write(index, card) {
if (this.onBoard()) {
if (!Array.isArray(this.state.board)) this.state.board = ["", "", "", "", ""];
this.state.board[index] = card;
return;
}
this.state.seats[this.seatIndex].cards[index] = card;
},
// Why the sheet is closing, because one caller needs to tell the difference.
//
// "filled" is the sheet finishing its own job — the last empty slot it was offering
// got a card. "dismissed" is the dealer leaving: Done, Escape, or a tap on the scrim.
// A single seat's cards don't care which. A showdown does: it walks the players one
// at a time, and the two answers are "next player" and "stop asking".
//
// The hooks are dropped before the callback runs, so a caller that opens the sheet
// again from inside `onDone` installs its own and this close can't fire twice.
close(reason = "dismissed") {
document.getElementById("cardPickerOverlay").hidden = true;
const done = this.hooks && this.hooks.onDone;
this.hooks = null;
if (done) done(reason);
},
// Every card already dealt somewhere — in front of a player or face up in the
// middle. The slot being edited is excluded, so re-picking the card that's already
// there isn't treated as a duplicate.
//
// The board belongs in here as much as the hands do, and in both directions: the
// seven of clubs cannot be on the flop and in somebody's hand, whichever of the two
// the dealer is filling in at the time.
cardsInUse() {
const used = new Map();
this.state.seats.forEach((player, seat) => {
(player.cards || []).forEach((card, slot) => {
if (!card) return;
if (seat === this.seatIndex && slot === this.cardIndex) return;
if (!this.onBoard() && seat === this.seatIndex && slot === this.cardIndex) return;
used.set(card.toUpperCase(), HoldemState.displayName(player, seat));
});
});
(this.state.board || []).forEach((card, slot) => {
if (!card) return;
if (this.onBoard() && slot === this.cardIndex) return;
used.set(card.toUpperCase(), "the board");
});
return used;
},
assign(card) {
const player = this.state.seats[this.seatIndex];
player.cards[this.cardIndex] = card;
this.write(this.cardIndex, card);
this.hooks.onPick(this.seatIndex, this.cardIndex, card);
// Straight on to the second card — a dealer picking hole cards is always
// picking two, and making them re-open the picker doubles the taps.
if (this.cardIndex === 0 && !player.cards[1]) {
this.cardIndex = 1;
// Straight on to the next empty slot — a dealer picking hole cards is always
// picking two and a flop is always three, and making them re-open the picker
// between each one multiplies the taps by the only thing the sheet is for.
const values = this.slotValues();
const next = values.findIndex((value, i) => i > this.cardIndex && !value);
if (next !== -1) {
this.cardIndex = next;
this.render();
return;
}
this.close();
this.close("filled");
},
clearSlot() {
const player = this.state.seats[this.seatIndex];
player.cards[this.cardIndex] = "";
this.write(this.cardIndex, "");
this.hooks.onPick(this.seatIndex, this.cardIndex, "");
this.render();
},
render() {
const player = this.state.seats[this.seatIndex];
const used = this.cardsInUse();
// What this slot is called out loud: the flop's three cards, then the turn, then
// the river — which is what a dealer is holding when they open this, rather than
// "card 4".
boardSlotLabel(slot) {
return ["Flop 1", "Flop 2", "Flop 3", "Turn", "River"][slot] || `Card ${slot + 1}`;
},
document.getElementById("cardPickerTitle").textContent =
`${HoldemState.displayName(player, this.seatIndex)} — card ${this.cardIndex + 1}`;
render() {
const used = this.cardsInUse();
const values = this.slotValues();
document.getElementById("cardPickerTitle").textContent = this.onBoard()
? `Board — ${this.boardSlotLabel(this.cardIndex).toLowerCase()}`
: `${HoldemState.displayName(this.state.seats[this.seatIndex], this.seatIndex)} — card ${this.cardIndex + 1}`;
// Which slot the next tap fills, switchable without closing the sheet.
const slots = document.getElementById("cardPickerSlots");
slots.innerHTML = "";
[0, 1].forEach(slot => {
values.forEach((_, slot) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "pickerSlot";
btn.classList.toggle("active", slot === this.cardIndex);
const value = player.cards[slot] || "";
const value = values[slot] || "";
btn.textContent = value || "—";
if (this.onBoard()) btn.title = this.boardSlotLabel(slot);
btn.classList.toggle("cardRed", /[HD]$/i.test(value));
btn.addEventListener("click", () => {
this.cardIndex = slot;
@@ -105,8 +177,12 @@ const CardPicker = {
btn.className = "pickerCard";
btn.classList.toggle("cardRed", suit.red);
btn.disabled = !!takenBy;
if (takenBy) btn.title = `Already dealt to ${takenBy}`;
if ((player.cards[this.cardIndex] || "").toUpperCase() === card) {
if (takenBy) {
btn.title = takenBy === "the board"
? "Already face up on the board"
: `Already dealt to ${takenBy}`;
}
if ((values[this.cardIndex] || "").toUpperCase() === card) {
btn.classList.add("selected");
}
@@ -46,7 +46,10 @@ const Toast = {
host.appendChild(toast);
// Next frame, so the entrance transition actually runs.
requestAnimationFrame(() => toast.classList.add("toastVisible"));
requestAnimationFrame(() => {
toast.classList.add("toastVisible");
this.publishHeight();
});
this.timer = setTimeout(
() => this.dismiss(),
@@ -61,6 +64,24 @@ const Toast = {
}
const host = document.getElementById("toastHost");
if (host) host.innerHTML = "";
this.publishHeight();
},
// How much of the bottom of the window this is standing on.
//
// The toast is fixed, so it is not in the flow and nothing scrolling a control into
// view knows it is there — measured on the desktop sweep, tabbing to Common rulings
// while a toast was up left the focus ring entirely behind it. The stylesheet adds
// this to the bar's own height to work out where a focused control has to stop, so
// it is published rather than guessed: the toast is one line or two depending on the
// message, and on a narrow screen the same message is taller again.
publishHeight() {
if (typeof document === "undefined" || !document.documentElement) return;
const host = document.getElementById("toastHost");
const height = host && host.firstElementChild
? Math.round(host.getBoundingClientRect().height)
: 0;
document.documentElement.style.setProperty("--toastHeight", `${height}px`);
},
};
@@ -20,7 +20,11 @@ const HandHistoryExport = {
// who owes what — doesn't work without cleaning every cell first. The commas were
// quoted correctly, so the file parsed; it just wouldn't add up.
toCsv(handHistory, standings = [], { notes = "" } = {}) {
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
// The rake gets its own column rather than being folded into the pot: the two
// numbers answer different questions — what was in the middle, and what the
// house took out of it — and a night's worth of the second one is what the box
// is counted against.
const rows = [["Hand #", "Time", "Pot", "House take", "Payouts"]];
// handHistory is stored newest-first; the export reads better oldest-first.
[...handHistory].reverse().forEach(entry => {
const payouts = (entry.payouts || [])
@@ -30,6 +34,7 @@ const HandHistoryExport = {
entry.handNumber,
this.formatTimestamp(entry.timestamp),
Money.forSheet(entry.pot),
Money.forSheet(entry.rake || 0),
payouts,
]);
});
@@ -49,7 +54,10 @@ const HandHistoryExport = {
rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down", "Notes"]);
standings.forEach(row => {
rows.push([
row.name,
// Somebody who has gone home is marked in the file the same way the
// panel marks them, because this is the copy that gets read at the
// end of the night when nobody remembers who has already been paid.
row.cashedOut ? `${row.name} (paid out)` : row.name,
Money.forSheet(row.in),
Money.forSheet(row.won),
Money.forSheet(row.net),
@@ -0,0 +1,285 @@
// What beats what.
//
// The dealer has always been the one who decides who won: the pot wizard asks "who won
// the hand?" and pays whoever gets ticked. That is the right default — most hands are
// won without a showdown, and at a home game the table reads the cards out loud between
// them. But when the board is on the felt and the hands are on their backs, the tool
// knows the answer, and reading it off a screen beats three people counting kickers.
//
// Cards are the same two-character strings the rest of the tool has always stored:
// a rank from `RANKS` and a suit from S, H, D, C. Case is not significant.
//
// Nothing in here touches the DOM, the table, or money. It compares cards, and the pot
// wizard decides what to do about it.
const HandRank = {
RANKS: "23456789TJQKA",
// High to low. The number is the hand's category and nothing else — kickers and
// the rest of the tie-breaking live in the `tiebreak` array beside it.
STRAIGHT_FLUSH: 8,
FOUR_OF_A_KIND: 7,
FULL_HOUSE: 6,
FLUSH: 5,
STRAIGHT: 4,
THREE_OF_A_KIND: 3,
TWO_PAIR: 2,
ONE_PAIR: 1,
HIGH_CARD: 0,
// 2 through 14. Null for anything that isn't a card, which is how a half-filled
// slot gets told apart from a bad one.
rankValue(card) {
if (typeof card !== "string" || card.length < 2) return null;
const index = this.RANKS.indexOf(card[0].toUpperCase());
return index < 0 ? null : index + 2;
},
suitOf(card) {
if (typeof card !== "string" || card.length < 2) return null;
const suit = card[card.length - 1].toUpperCase();
return "SHDC".includes(suit) ? suit : null;
},
isCard(card) {
return this.rankValue(card) !== null && this.suitOf(card) !== null;
},
// Singular and plural, for reading a hand out loud.
NAMES: {
14: ["ace", "aces"], 13: ["king", "kings"], 12: ["queen", "queens"],
11: ["jack", "jacks"], 10: ["ten", "tens"], 9: ["nine", "nines"],
8: ["eight", "eights"], 7: ["seven", "sevens"], 6: ["six", "sixes"],
5: ["five", "fives"], 4: ["four", "fours"], 3: ["three", "threes"],
2: ["two", "twos"],
},
name(rank, plural = false) {
const pair = this.NAMES[rank];
return pair ? pair[plural ? 1 : 0] : "?";
},
// ---- The five-card hand -------------------------------------------------------
// Exactly five cards, ranked. Everything else is built on this.
//
// Five rather than seven because five is the hand a player actually holds, and it
// is small enough to check by hand — which is the whole argument for doing it this
// way. The seven-card version below tries all twenty-one five-card hands and keeps
// the best, which is a hand of poker's own definition of "your best five" written
// out literally. A dealer resolves one pot a hand with at most a dozen players in
// it; two hundred and fifty comparisons of five cards is not a cost worth trading
// a subtler algorithm's correctness for.
evaluate5(cards) {
if (!Array.isArray(cards) || cards.length !== 5) return null;
if (!cards.every(card => this.isCard(card))) return null;
const ranks = cards.map(card => this.rankValue(card)).sort((a, b) => b - a);
const suits = cards.map(card => this.suitOf(card));
const flush = suits.every(suit => suit === suits[0]);
// How many of each rank, ordered by how many first and by rank second — so
// the front of this list is always the rank that decides the hand.
const counts = new Map();
ranks.forEach(rank => counts.set(rank, (counts.get(rank) || 0) + 1));
const groups = [...counts.entries()]
.sort((a, b) => (b[1] - a[1]) || (b[0] - a[0]));
const shape = groups.map(([, count]) => count);
const byGroup = groups.map(([rank]) => rank);
const straightHigh = this.straightHigh(ranks);
const made = (category, tiebreak) => ({ category, tiebreak, cards: [...cards] });
if (flush && straightHigh) return made(this.STRAIGHT_FLUSH, [straightHigh]);
if (shape[0] === 4) return made(this.FOUR_OF_A_KIND, byGroup);
if (shape[0] === 3 && shape[1] === 2) return made(this.FULL_HOUSE, byGroup);
if (flush) return made(this.FLUSH, ranks);
if (straightHigh) return made(this.STRAIGHT, [straightHigh]);
if (shape[0] === 3) return made(this.THREE_OF_A_KIND, byGroup);
if (shape[0] === 2 && shape[1] === 2) return made(this.TWO_PAIR, byGroup);
if (shape[0] === 2) return made(this.ONE_PAIR, byGroup);
return made(this.HIGH_CARD, ranks);
},
// The top card of the straight these five ranks make, or null.
//
// The wheel is the exception the rest of the ordering has to be told about: A-2-3-4-5
// is a straight, and it is the *lowest* one, so its ace counts as five rather than
// as fourteen. Getting that wrong is how A-2-3-4-5 comes to beat 10-J-Q-K-A.
straightHigh(sortedDesc) {
const distinct = [...new Set(sortedDesc)];
if (distinct.length !== 5) return null;
if (distinct[0] - distinct[4] === 4) return distinct[0];
// Ace, five, four, three, two.
if (distinct[0] === 14 && distinct[1] === 5 && distinct[4] === 2) return 5;
return null;
},
// ---- The best five out of however many ----------------------------------------
// The best five-card hand available from five, six or seven cards — hole cards and
// board together, which is what Hold'em asks for. Returns null if there aren't five
// real cards, which is the ordinary state of a hand still being dealt.
best(cards) {
const real = (cards || []).filter(card => this.isCard(card));
if (real.length < 5) return null;
// A card in two places at once is a mis-entry, not a hand — say so rather than
// ranking a board that has the same king on it twice.
if (new Set(real.map(card => card.toUpperCase())).size !== real.length) return null;
let best = null;
this.eachFive(real, five => {
const hand = this.evaluate5(five);
if (!best || this.compare(hand, best) > 0) best = hand;
});
return best;
},
// Every five-card subset, without building an array of them.
eachFive(cards, visit) {
const n = cards.length;
for (let a = 0; a < n - 4; a++) {
for (let b = a + 1; b < n - 3; b++) {
for (let c = b + 1; c < n - 2; c++) {
for (let d = c + 1; d < n - 1; d++) {
for (let e = d + 1; e < n; e++) {
visit([cards[a], cards[b], cards[c], cards[d], cards[e]]);
}
}
}
}
}
},
// Positive if `a` wins, negative if `b` does, zero for a chop. A chop is a real
// answer here rather than a rounding of one: two players playing the same board
// split the pot, and the fifth card decides it far more often than a table expects.
compare(a, b) {
if (!a && !b) return 0;
if (!a) return -1;
if (!b) return 1;
if (a.category !== b.category) return a.category - b.category;
const length = Math.max(a.tiebreak.length, b.tiebreak.length);
for (let i = 0; i < length; i++) {
const diff = (a.tiebreak[i] || 0) - (b.tiebreak[i] || 0);
if (diff !== 0) return diff;
}
return 0;
},
// ---- Saying it out loud -------------------------------------------------------
// What a dealer says when they turn the hand over. Named to the level that settles
// it and no further: "two pair, aces and eights" is the call, and the kicker only
// gets a mention where the tool has had to use one to break a tie.
describe(hand) {
if (!hand) return "";
const [first, second] = hand.tiebreak;
switch (hand.category) {
case this.STRAIGHT_FLUSH:
return first === 14 ? "Royal flush" : `Straight flush, ${this.name(first)} high`;
case this.FOUR_OF_A_KIND:
return `Four of a kind, ${this.name(first, true)}`;
case this.FULL_HOUSE:
return `Full house, ${this.name(first, true)} full of ${this.name(second, true)}`;
case this.FLUSH:
return `Flush, ${this.name(first)} high`;
case this.STRAIGHT:
return `Straight, ${this.name(first)} high`;
case this.THREE_OF_A_KIND:
return `Three of a kind, ${this.name(first, true)}`;
case this.TWO_PAIR:
return `Two pair, ${this.name(first, true)} and ${this.name(second, true)}`;
case this.ONE_PAIR:
return `Pair of ${this.name(first, true)}`;
default:
return `${this.name(first).charAt(0).toUpperCase()}${this.name(first).slice(1)} high`;
}
},
// The same call with the article a table would put in front of it. Not derivable
// from the first letter: it is "a full house" and "a flush", but "four of a kind"
// and "two pair" take none at all, and "an ace high" is not something anybody says.
// So it belongs to the category, like the name does.
ARTICLED: {
8: hand => (hand.tiebreak[0] === 14 ? "a royal flush" : "a"),
7: () => null,
6: () => "a",
5: () => "a",
4: () => "a",
3: () => null,
2: () => null,
1: () => "a",
0: () => null,
},
// "wins with X" — the phrase, ready to drop into a sentence.
phrase(hand) {
if (!hand) return "the best hand";
const described = this.describe(hand);
const article = (this.ARTICLED[hand.category] || (() => null))(hand);
if (article === null) return described.toLowerCase();
if (article !== "a") return article;
return `a ${described.toLowerCase()}`;
},
// ---- The showdown -------------------------------------------------------------
// Rank a set of players against a board.
//
// `players` is [{ id, cards }, ...] — the two in front of each of them. Every player
// is returned whether or not they could be ranked, in the order they were given, so
// the caller can show a row for each and say which ones it could not read. `place`
// is 1 for the winner and shared by a chop; a player whose cards aren't known has a
// null hand and no place.
//
// `complete` is the question the pot wizard actually asks: can this be trusted to
// pay the table. It is only true when the board is all five cards and every single
// player handed in has two of their own — one unknown hand and the tool cannot say
// who won, only who is winning so far, which is not the same thing and must not be
// dressed up as it.
showdown(board, players) {
const boardCards = (board || []).filter(card => this.isCard(card));
const boardComplete = boardCards.length === 5;
const ranked = (players || []).map(player => {
const hole = (player.cards || []).filter(card => this.isCard(card));
const hand = hole.length === 2 && boardComplete
? this.best([...hole, ...boardCards])
: null;
return { id: player.id, cards: hole, hand, place: null };
});
const readable = ranked.filter(row => row.hand);
// A duplicated card anywhere means somebody has been mis-entered, and the
// ranking would be of a deck that doesn't exist.
const all = [...boardCards, ...ranked.flatMap(row => row.cards)].map(c => c.toUpperCase());
const duplicated = new Set(all).size !== all.length;
const sorted = [...readable].sort((a, b) => this.compare(b.hand, a.hand));
let place = 0;
sorted.forEach((row, i) => {
if (i === 0 || this.compare(row.hand, sorted[i - 1].hand) !== 0) place = i + 1;
row.place = place;
});
const winners = sorted.filter(row => row.place === 1);
const complete = boardComplete && !duplicated &&
ranked.length > 0 && readable.length === ranked.length;
return {
players: ranked,
winners: complete ? winners.map(row => row.id) : [],
boardComplete,
duplicated,
complete,
// Who the tool could not read, so the dialog can name them rather than
// just declining to help.
unknown: ranked.filter(row => !row.hand).map(row => row.id),
};
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = HandRank;
}
+602 -18
View File
@@ -5,6 +5,32 @@ document.addEventListener("DOMContentLoaded", () => {
// `hidden` attribute they all already toggle.
Modal.mount();
// ---- A scroll is not an edit ----
//
// A focused `<input type="number">` is a spinbutton, and a browser lets the mouse
// wheel drive it. Every other way of driving one is deliberate: the arrow keys, the
// little stepper arrows, typing. Scrolling is the one actuation nobody means — the
// dealer is reaching for another panel and the cursor happens to be over a field
// they touched a moment ago.
//
// Measured on the running page, at 1280x900 with the sidebar open. Focus the small
// blind, scroll: $5 becomes $5.50. Focus a seat's stack, scroll: $500 becomes $501.
// Both are written straight through to the saved table, because these fields save on
// `input` like everything else here. Nothing is announced, nothing looks wrong, and
// the tool has invented a chip.
//
// Blur rather than preventDefault. Cancelling the event would stop the value moving
// and stop the page moving with it, which turns a silent bug into a sidebar that
// mysteriously will not scroll. Dropping focus lets the scroll do what the dealer
// asked for and leaves the number alone — and losing focus on a field you have just
// scrolled away from is honest rather than surprising.
//
// Captured at the document, so it covers the fields the seat editor and the chip
// legend build on the fly as well as the ones in the markup.
document.addEventListener("wheel", event => {
if (Money.wheelWouldEdit(event.target, document.activeElement)) event.target.blur();
}, { passive: true });
// Undo works by swapping the whole table back to a snapshot. `state` is handed out
// by reference to every module at mount time, so it has to be refilled in place —
// reassigning the variable would leave the renderer and the betting bar pointing at
@@ -56,7 +82,84 @@ document.addEventListener("DOMContentLoaded", () => {
},
onSeatLongPress: index => SeatMenu.open(state, seatMenuHooks, index),
// The waiting list, from the Session panel and from an empty chair's menu.
// The state change goes inside runUndoable, not before it: the snapshot is
// taken as the first thing that happens, so anything done ahead of it is
// already baked into what Undo would restore.
seatWaiting: (id, index) => {
const entry = (state.waitingList || []).find(row => row.id === id);
const seat = state.seats[index];
if (!entry || !seat) {
Toast.show("They aren't on the list any more.", { duration: 3000 });
hooks.rerender();
return;
}
const occupied = HoldemState.seatInPlay(seat);
if (occupied) {
Toast.show(`${HoldemState.displayName(seat, index)} is already in that seat.`, { duration: 3500 });
hooks.rerender();
return;
}
runUndoable(`${entry.name} takes seat ${index + 1}. Add their chips when they buy in.`,
() => HoldemState.seatFromWaitingList(state, id, index));
},
dropWaiting: id => {
const entry = (state.waitingList || []).find(row => row.id === id);
if (!entry) return;
runUndoable(`${entry.name} taken off the waiting list.`,
() => HoldemState.removeFromWaitingList(state, id));
},
onGiveButton: index => seatMenuHooks.makeDealer(index),
// Removing a chip colour, which is only sometimes a small thing.
//
// A colour nobody has counted is a row in a legend, and taking it out should
// cost a click and nothing else. A colour seats are holding is money: the
// legend is what prices their chips, so the row leaving takes their chips off
// the table with it — and it cannot be put back, because a colour added again
// is a new one and the counts belong to the old.
//
// So the friction is on the second case only. A confirmation that fires every
// time is one nobody reads by the third night, and the one that matters here
// would go the same way as the four before it that didn't.
removeChipColor: async id => {
const usage = HoldemState.chipColorUsage(state, id);
if (!usage.chip) return;
const name = Number(usage.chip.value) > 0
? `${Money.format(usage.chip.value)} chip`
: "chip colour";
if (usage.total !== 0) {
const seatCount = `${usage.seats.length} ${usage.seats.length === 1 ? "seat" : "seats"}`;
const held = usage.seats.map(seat => `${seat.name} ${seat.count}`).join(", ");
const ok = await Dialog.confirm({
// Both directions are named rather than only the loss. Going up is
// the odd one — seats falling back to the table's standing ante once
// their chips are gone — and a dealer who is told the total will move
// and then finds it moved the other way has been told something false.
title: usage.total > 0
? `Remove the ${name} and ${Money.format(usage.total)} with it?`
: `Remove the ${name} and put the table up ${Money.format(-usage.total)}?`,
message:
`${usage.chips} of them are counted onto ${seatCount} — ${held} — and the table ` +
`${usage.total > 0 ? "loses" : "gains"} ${Money.format(Math.abs(usage.total))} ` +
`when the colour goes. Undo will put it back; adding the colour again will not.`,
confirmLabel: "Remove it",
danger: usage.total > 0,
});
if (!ok) return;
}
runUndoable(
usage.total > 0
? `${name} removed — ${Money.format(usage.total)} off the table.`
: usage.total < 0
? `${name} removed — table up ${Money.format(-usage.total)}.`
: `${name} removed.`,
() => HoldemState.removeChipColor(state, id));
},
};
const seatMenuHooks = {
@@ -96,6 +199,39 @@ document.addEventListener("DOMContentLoaded", () => {
});
},
// Somebody is done for the night. The dealer counts their chips out, the chair
// opens, and both halves of what happened stay on the record — the cash the box
// took from them, and the chips it handed back. Wiping the seat by hand did
// neither: it left the bank check declaring their whole stack missing for the
// rest of the night.
cashOut: async index => {
const allowed = HoldemState.canCashOut(state, index);
if (!allowed.ok) {
Toast.show(allowed.reason, { duration: 4000 });
return;
}
const preview = HoldemState.cashOutPreview(state, index);
const result = preview.net === 0
? "even for the night"
: preview.net > 0
? `up ${Money.format(preview.net)}`
: `down ${Money.format(Math.abs(preview.net))}`;
const chips = preview.breakdown ? ` — ${preview.breakdown}` : "";
const ok = await Dialog.confirm({
title: `Cash out ${preview.name}?`,
message: `Count out ${Money.format(preview.chips)}${chips}. They bought in for ` +
`${Money.format(preview.boughtIn)}, so they leave ${result}. The chair opens up, and the ` +
`night's record keeps both their buy-in and this payout so the bank still balances.`,
confirmLabel: `Pay ${Money.format(preview.chips)}`,
});
if (!ok) return;
runUndoable(`${preview.name} cashed out for ${Money.format(preview.chips)}.`,
() => HoldemState.cashOut(state, index));
},
// Two players change chairs. Undoable like every other seat action, because a
// swap is easy to aim wrong and the seats look alike at a glance.
swapSeats: (from, to) => {
@@ -110,12 +246,16 @@ document.addEventListener("DOMContentLoaded", () => {
},
makeDealer: index => {
// Moving the button to where it already is is not a move. Saying so costs a
// toast nobody asked for, and mid-hand it would take the blinds back off the
// felt and re-post the same chips to say nothing happened.
if (index === state.dealerSeatIndex) return;
// Before the deal there's nothing posted, so this is just moving a marker.
// After it, the blinds have to follow the button or the badges and the
// money disagree — but that's a real thing happening to real chips, and
// the dealer gets told rather than finding out later.
const willRepost = state.bettingOpen && Betting.onlyBlindsIn(state) &&
HoldemState.unresolvedContributions(state) > Betting.EPSILON_CHIPS;
const willRepost = Betting.blindsArePosted(state);
seatAction(index, willRepost
? `Button on ${nameOf(index)} — blinds taken back and re-posted.`
@@ -142,18 +282,29 @@ document.addEventListener("DOMContentLoaded", () => {
if (partner === index || partner === null) partner = Betting.nextLiveIndex(state, index);
const label = which === "sb" ? "small blind" : "big blind";
seatAction(index, `${nameOf(index)} posts the ${label}.`, () => {
// Only a hand that is already open has blinds to move. Before the deal this
// is aiming the next hand, and re-posting would have dealt it — the dealer
// says who the blind is well before they say "cards".
const posted = Betting.blindsArePosted(state);
seatAction(index, posted
? `${nameOf(index)} posts the ${label}.`
: `${nameOf(index)} has the ${label} next hand.`, () => {
state.blindOverride = which === "sb"
? { sbIndex: index, bbIndex: partner }
: { sbIndex: partner, bbIndex: index };
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
if (posted) Betting.repostBlinds(state);
});
},
clearBlindOverride: () => seatAction(state.dealerSeatIndex, "Blinds follow the button again.", () => {
state.blindOverride = null;
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
}),
clearBlindOverride: () => {
const posted = Betting.blindsArePosted(state);
seatAction(state.dealerSeatIndex, posted
? "Blinds follow the button again — taken back and re-posted."
: "Blinds follow the button again.", () => {
state.blindOverride = null;
if (posted) Betting.repostBlinds(state);
});
},
editSeat: index => Render.toggleSeatEditor(index),
@@ -185,6 +336,22 @@ document.addEventListener("DOMContentLoaded", () => {
Render.mount(state, hooks);
// Table Setup used to ship open, so the tool's first screen was its most crowded
// one: seat count, blind direction, dedicated dealer, both blinds, starting stack
// and two sub-panels, all under a felt the dealer had come here to look at. It is
// the right panel to have open on a table nobody has touched, and the wrong one to
// have open on the fourth hand of a night.
//
// Decided once, here, rather than on every render — a panel that reopened itself
// whenever the table emptied would fight the dealer who had just shut it.
(() => {
const panel = document.getElementById("tableSetupPanel");
if (!panel) return;
const fresh = (Number(state.handNumber) || 1) === 1 &&
!state.seats.some(seat => HoldemState.seatInPlay(seat));
panel.open = fresh;
})();
const openPotWizard = () => {
// Chips still in front of players belong in the pot being priced, so bank them
// before the wizard reads anyone's contribution.
@@ -192,11 +359,20 @@ document.addEventListener("DOMContentLoaded", () => {
hooks.save(state);
Render.renderAll();
PotWizard.open(state, payouts => {
PotWizard.open(state, (payouts, { rake = 0 } = {}) => {
const paid = payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
runUndoable(paid ? `Paid ${paid}.` : "Hand closed with nothing to pay out.", () => {
const withRake = rake > 0 ? `${paid}, house ${Money.format(rake)}` : paid;
runUndoable(withRake ? `Paid ${withRake}.` : "Hand closed with nothing to pay out.", () => {
if (rake > 0) {
state.rakeTaken = Money.round((Number(state.rakeTaken) || 0) + rake);
}
// Anything the pot was priced with but nobody actually paid — the
// base pot and counted chips of the no-live-betting flow — comes off
// the stacks that owe it before the winner is paid. Otherwise the
// table gains the whole pot out of nowhere.
Betting.collectAssumedContributions(state);
if (payouts.length > 0) {
HoldemState.recordHand(state, payouts);
HoldemState.recordHand(state, payouts, rake);
// Winnings go straight back in front of the player, so the next
// hand's stacks are already right.
Betting.creditWinnings(state, payouts);
@@ -219,6 +395,17 @@ document.addEventListener("DOMContentLoaded", () => {
save: hooks.save,
rerender: hooks.rerender,
resolvePot: openPotWizard,
keepStorage: () => askToKeepStorage(),
// The first thing a dealer does on a table nobody is sitting at. Opens the
// seat editor on the first free chair — the same panel tapping the seat gives
// them, so the button teaches the gesture rather than replacing it.
seatFirstPlayer: () => {
const index = state.seats.findIndex(seat => !HoldemState.seatInPlay(seat));
Render.toggleSeatEditor(index < 0 ? 0 : index);
const panel = document.getElementById("seatEditorPanel");
Render.bringIntoView(panel, { block: "nearest" });
},
beginAction: label => {
actionHistory.push({ label, snapshot: snapshotState() });
@@ -300,12 +487,11 @@ document.addEventListener("DOMContentLoaded", () => {
// in progress. Shrinking the table throws these away permanently, and it also
// orphans them in the session standings, where they keep their winnings but lose
// the stack and buy-in the settle-up is worked out from.
const seatHasSomeoneIn = player =>
!!(player.name && player.name.trim()) ||
player.inHand ||
(Number(player.stack) || 0) > 0 ||
(Number(player.buyIns) || 0) > 0 ||
(Number(player.amountOverride) || 0) > 0;
// One question, asked in one place. This predicate existed here, in the seat menu,
// in the action bar and twice in the state module, in four slightly different
// spellings — and a chair that counts as occupied for the waiting list but empty
// for the seat count is how somebody's chips get thrown away.
const seatHasSomeoneIn = player => HoldemState.seatInPlay(player);
document.getElementById("seatCountInput").addEventListener("change", async e => {
const requested = Math.max(2, Math.min(24, Math.floor(Number(e.target.value)) || state.seatCount));
@@ -349,9 +535,24 @@ document.addEventListener("DOMContentLoaded", () => {
Render.renderTableHint();
});
document.getElementById("trackBoardToggle").addEventListener("change", e => {
state.trackBoard = e.target.checked;
// Turning it off leaves whatever was entered in place rather than wiping it —
// a dealer who taps this by mistake mid-hand can tap it back and the flop is
// still there. The next hand clears it the way it clears everything else.
hooks.save(state);
Render.renderAll();
});
document.getElementById("tableNotes").addEventListener("input", e => {
state.notes = e.target.value;
hooks.save(state);
// The one field on the page that must not go through renderAll: it would write
// the textarea's own value back into it on every keystroke and put the caret at
// the end. The panel's shut-state summary still has to keep up, though, and it
// is the only thing on the page that reads the notes — so it gets called on its
// own. It touches the six <span>s in the sidebar headers and nothing else.
Render.renderPanelGlances();
});
document.getElementById("addChipColorBtn").addEventListener("click", () => {
@@ -364,6 +565,59 @@ document.addEventListener("DOMContentLoaded", () => {
Render.renderAll();
});
// A misdeal. Everything in the middle goes back to whoever put it there, and the
// same button deals the same hand again.
// The seats holding the table's starting stack, written down as cash taken.
document.getElementById("bankRecordBuyInsBtn").addEventListener("click", async () => {
const starting = HoldemState.seatsOwingCash(state).filter(row => row.fromStartingStack);
if (starting.length === 0) return;
const total = Money.round(starting.reduce((sum, row) => sum + row.chips, 0));
const ok = await Dialog.confirm({
title: `Write down ${Money.format(total)} taken?`,
message: `${starting.map(row => `${row.name} ${Money.format(row.chips)}`).join(", ")} — the starting stack ` +
`each of them is playing with. Only do this if the cash is actually in the box.`,
confirmLabel: "Write it down",
});
if (!ok) return;
runUndoable(`${Money.format(total)} recorded from ${starting.length} ${starting.length === 1 ? "player" : "players"}.`,
() => HoldemState.recordStartingBuyIns(state));
});
document.getElementById("waitingListForm").addEventListener("submit", e => {
e.preventDefault();
const field = document.getElementById("waitingNameInput");
const name = field.value.trim();
if (!name) return;
field.value = "";
runUndoable(`${name} added to the waiting list.`, () => HoldemState.addToWaitingList(state, name));
field.focus();
});
document.getElementById("misdealBtn").addEventListener("click", async () => {
const inPlay = HoldemState.unresolvedContributions(state);
const acted = state.seats.filter(p => p.hasActed).length;
const ok = await Dialog.confirm({
title: "Misdeal — take the hand back?",
message: inPlay > Betting.EPSILON_CHIPS
? `${Money.format(inPlay)} goes back to the players who put it in, cards are cleared, and the same ` +
`button deals again.${acted > 1 ? " Note that most rooms let a deal stand once two players have acted." : ""}`
: "Cards are cleared and the same button deals again. Nothing has been put in yet, so no chips move.",
confirmLabel: "Take it back",
});
if (!ok) return;
// The label is both the toast a dealer reads now and the words on the undo
// control afterwards, so it says what happened to the money rather than naming
// the ruling: "the misdeal" told a table nothing about where their chips went.
const label = inPlay > Betting.EPSILON_CHIPS
? `Misdeal — ${Money.format(inPlay)} returned, same button deals again.`
: "Misdeal — nothing was in yet, same button deals again.";
runUndoable(label, () => Betting.takeHandBack(state));
});
document.getElementById("nextHandBtn").addEventListener("click", async () => {
// Moving on wipes every seat's contribution, so an unresolved pot is simply
// destroyed — measured at a four-handed table, pressing this with $8 in the
@@ -448,6 +702,70 @@ document.addEventListener("DOMContentLoaded", () => {
}, 0);
});
// Focus that lands underneath something fixed is moved out from under it.
//
// scroll-padding-bottom in the stylesheet is half of WCAG 2.4.11 and only half. It
// works when the browser has to scroll to reach the control at all — then the
// padding tells it where to stop. A control the browser already considers visible is
// one it will not scroll to at all, and a fixed overlay sitting on top of it does
// not change that: measured on a 1280x900 desktop, tabbing to Common rulings while a
// toast was up left it 100% covered, and scrollIntoView({block:"nearest"}) moved
// nothing, because "nearest" was already satisfied.
//
// So the overlap is worked out here and the page is scrolled by exactly it. Named
// overlays rather than every fixed element on the page, because this runs on every
// focus change and walking the whole DOM to find three known things would be work
// done ten times a second to reach the same answer. Anything inside one of them is
// left alone — the bar's own buttons are meant to be on top of the page.
const FIXED_OVERLAYS = "#actionBar, #toastHost, #seatEditorPanel";
document.addEventListener("focusin", event => {
const el = event.target;
if (!el || typeof el.getBoundingClientRect !== "function") return;
if (el.closest && el.closest(FIXED_OVERLAYS)) return;
const box = el.getBoundingClientRect();
if (box.width === 0 || box.height === 0) return;
let lift = 0;
document.querySelectorAll(FIXED_OVERLAYS).forEach(node => {
const style = getComputedStyle(node);
if (node.hidden || style.visibility === "hidden" || style.display === "none") return;
if (style.position !== "fixed") return;
const over = node.getBoundingClientRect();
if (over.width === 0 || over.height === 0) return;
// Only what is actually in the way: an overlay off to one side, like the
// rail on a phone held sideways, is not covering a control in the middle.
if (box.left >= over.right || box.right <= over.left) return;
if (box.bottom <= over.top || box.top >= over.bottom) return;
lift = Math.max(lift, box.bottom - over.top + 8);
});
// Instant rather than smooth. This is the page getting out of the way of
// something the dealer has already arrived at, and animating it would put the
// control they are typing into in motion under them.
if (lift > 0) window.scrollBy({ top: lift, behavior: "auto" });
// Scrolling is not always enough. The page only has as much room below its
// content as the padding under the app gives it, and that is sized for the bar
// — the toast stands on top of the bar, so a control at the very end of the page
// can be lifted clear of one and not the other. Measured at 1280x900: 94px of
// lift wanted, 26px of page to do it in.
//
// Sizing the padding for the toast as well would fix it by making the page grow
// and shrink by 64px every time anything is announced, which is a worse thing to
// do to a dealer than the fault. So the toast gives way instead: it is an
// eight-second message about something that has already happened, and it is
// standing on a control somebody has just navigated to. Only after the scroll
// has been tried, so a toast that is not in the way is left alone.
const toast = document.querySelector("#toastHost .toast");
if (!toast) return;
const after = el.getBoundingClientRect();
const over = toast.getBoundingClientRect();
const stillCovered = after.left < over.right && after.right > over.left &&
after.bottom > over.top && after.top < over.bottom;
if (stillCovered) Toast.dismiss();
});
document.getElementById("resolvePotBtn").addEventListener("click", openPotWizard);
[
@@ -462,8 +780,48 @@ document.addEventListener("DOMContentLoaded", () => {
});
});
// The house's cut. Kept as a group rather than three loose fields, because
// migrate() guarantees the shape and half a rake object would be a table nobody
// could read back.
const readRake = () => {
const percent = Number(document.getElementById("rakePercentInput").value);
const cap = Number(document.getElementById("rakeCapInput").value);
state.rake = {
percent: Number.isFinite(percent) ? Math.min(100, Math.max(0, percent)) : 0,
cap: Number.isFinite(cap) && cap > 0 ? Money.round(cap) : null,
noFlopNoDrop: document.getElementById("rakeNoFlopInput").checked,
};
hooks.save(state);
Render.renderAll();
};
["rakePercentInput", "rakeCapInput"].forEach(id => {
document.getElementById(id).addEventListener("input", readRake);
});
document.getElementById("rakeNoFlopInput").addEventListener("change", readRake);
document.getElementById("closeSeatEditorBtn").addEventListener("click", () => Render.closeSeatEditor());
// The two ways out of the sheet that every other overlay in the tool already had.
//
// Modal.mount() covers the six whose ids end in "Overlay"; this one cannot join
// them, because above 700px it is not a sheet at all — it is the sidebar panel the
// seat's fields live in, and a dialog is the wrong thing for Modal to make of it.
// So the two behaviours it was missing are wired here against the same state the
// scrim is drawn from.
document.getElementById("seatSheetScrim")
.addEventListener("click", () => Render.closeSeatEditor());
// Not in capture, and behind Modal's own check: a card picker opened *from* the
// sheet is on top of it, and Escape there means "stop picking a card", not "throw
// the seat away". Modal stops that event before this listener ever sees it; the
// guard is for the case where it hasn't, and costs nothing.
document.addEventListener("keydown", event => {
if (event.key !== "Escape") return;
if (Modal.open.length > 0) return;
if (!document.body.classList.contains("seatSheetOpen")) return;
Render.closeSeatEditor();
});
document.getElementById("addChipsConfirmBtn").addEventListener("click", () => AddChips.confirm());
document.getElementById("addChipsCancelBtn").addEventListener("click", () => AddChips.close());
document.getElementById("addChipsOverlay").addEventListener("click", e => {
@@ -546,7 +904,7 @@ document.addEventListener("DOMContentLoaded", () => {
// costs a screen reader the sentence.
if (section.dataset.short) jump.setAttribute("aria-label", heading.textContent);
jump.addEventListener("click", () => {
section.scrollIntoView({ block: "start", behavior: "smooth" });
Render.bringIntoView(section, { block: "start" });
// The heading is what the dealer is now reading, so that is where the
// keyboard and a screen reader should be too.
heading.setAttribute("tabindex", "-1");
@@ -598,6 +956,72 @@ document.addEventListener("DOMContentLoaded", () => {
Toast.show("Hand history cleared.", { duration: 2500 });
});
document.getElementById("saveTableBtn").addEventListener("click", () => {
const blob = new Blob([HoldemState.toBackup(state)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = HoldemState.backupFileName();
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
Toast.show(`Table saved as ${link.download}.`, { duration: 3500 });
});
document.getElementById("loadTableBtn").addEventListener("click", () => {
document.getElementById("loadTableInput").click();
});
document.getElementById("loadTableInput").addEventListener("change", async e => {
const file = e.target.files && e.target.files[0];
// Cleared straight away so picking the same file twice still fires a change.
e.target.value = "";
if (!file) return;
let text;
try {
text = await file.text();
} catch {
Toast.show("That file couldn't be read.", { duration: 4000 });
return;
}
const result = HoldemState.fromBackup(text);
if (result.error) {
Toast.show(result.error, { duration: 5000 });
return;
}
// Loading replaces everything: stacks, buy-ins, the button, the log. Naming
// what is about to be lost is the difference between a dealer choosing that
// and discovering it — and the current table might be the live one.
const here = HoldemState.sessionStandings(state).length;
const incoming = HoldemState.sessionStandings(result.table).length;
const when = result.savedAt ? new Date(result.savedAt) : null;
const saidWhen = when && !Number.isNaN(when.getTime())
? ` It was saved ${when.toLocaleString()}.`
: "";
const ok = await Dialog.confirm({
title: "Replace this table?",
message:
`The file has a table with ${incoming} ${incoming === 1 ? "player" : "players"} in it.${saidWhen} ` +
`Loading it replaces the table on screen — ${here} ${here === 1 ? "player" : "players"}, ` +
`${(state.handHistory || []).length} logged ${(state.handHistory || []).length === 1 ? "hand" : "hands"}. ` +
`Undo will bring it back for a few seconds; after that it is gone.`,
confirmLabel: "Load it",
danger: true,
});
if (!ok) return;
runUndoable("loading a saved table", () => {
// Replace the contents rather than the reference: everything on the page
// holds a pointer to this object.
Object.keys(state).forEach(key => { delete state[key]; });
Object.assign(state, result.table);
});
});
// The filter is how the dealer looks at the log, not something about the table, so
// it lives on the view: it isn't saved, isn't undoable, and doesn't reach the other
// tab. Clearing the history clears it too, since there is nothing left to filter.
@@ -629,4 +1053,164 @@ document.addEventListener("DOMContentLoaded", () => {
"The saved table couldn't be read, so this one started empty.",
{ duration: 12000 });
}
// ---- Keeping the screen on ----
//
// The lock itself is taken and dropped in renderAll, because that is the one place
// every start and end of a hand already passes through. All that lives here is the
// dealer's say in it: the toggle, and the way out offered by the toast the first
// time it takes hold.
WakeLock.hooks = {
turnOff: () => {
state.keepScreenAwake = false;
hooks.save(state);
Render.renderAll();
Toast.show("The screen will sleep as usual. Table Setup has it if you want it back.");
},
};
WakeLock.start(() => state);
document.getElementById("keepScreenAwakeToggle").addEventListener("change", e => {
state.keepScreenAwake = e.target.checked;
hooks.save(state);
// renderAll does the acquiring and releasing, so turning this off mid-hand
// drops the lock in the same tick rather than at the end of the hand.
Render.renderAll();
});
// ---- Asking the browser to keep the night ----
//
// Reported rather than announced. A dealer opening Save & reset is already thinking
// about losing the table, and that is the only moment this is worth a sentence.
const showStorageDurability = async () => {
const hint = document.getElementById("storageDurabilityHint");
const button = document.getElementById("keepStorageBtn");
if (!hint || !button) return;
if (!HoldemState.supportsDurableStorage()) {
// Nothing to say and nothing to offer. A browser this old still keeps the
// table exactly as well as it did yesterday.
hint.hidden = true;
button.hidden = true;
return;
}
const durable = await HoldemState.isDurable();
hint.hidden = false;
hint.textContent = durable
? "This browser has promised to keep it: the table survives the phone running short on space."
: "This browser may clear the table on its own if the phone runs short on space.";
button.hidden = durable;
};
// Fires on the first deal of every session, which costs nothing after the first
// time it is granted — persisted() is checked before persist() is asked for.
const askToKeepStorage = async () => {
await HoldemState.requestDurable();
showStorageDurability();
};
document.getElementById("keepStorageBtn").addEventListener("click", async () => {
const granted = await HoldemState.requestDurable();
showStorageDurability();
// Said either way, because a button that sometimes does nothing visible is a
// button a dealer presses four more times.
Toast.show(granted
? "This browser will keep the table now."
: "This browser wouldn't promise. Save the table to a file if tonight matters.");
});
showStorageDurability();
// ---- Working with no signal ----
//
// The night already survives anything — it lives in localStorage — but the page
// did not. Reload in a back room with one bar of signal and a dealer with nine
// players waiting got a browser error page. The worker below keeps the tool itself
// on the phone; see wwwroot/casino/sw.js for what it does and doesn't cache.
//
// The list of files to keep is read off this document rather than written down in
// the worker. Every stylesheet and script the page actually loaded is right here in
// the DOM, already carrying the ?v= release string from _Layout — so the list
// cannot drift from the markup, which is the way a hand-kept one always fails: it
// stays right until somebody adds a script tag, and then the tool is quietly
// offline-broken until a dealer finds out the hard way.
const MANIFEST_URL = "/casino/holdem.webmanifest";
// The icons are the one part of the shell the document doesn't mention: the browser
// reads them out of the manifest itself, so no <link> or <script> in the DOM points
// at them and the sweep above misses all four. An installed app that was launched
// with no signal then had no icon to show for itself.
//
// Read out of the manifest rather than typed out here, for the same reason the rest
// of the list is read out of the document — a copy of a list is a list that is one
// edit away from being wrong, and this particular wrongness is invisible until
// somebody is standing in a back room with no signal.
const manifestIcons = async () => {
try {
// The cache before the network, which is backwards from how the rest of this
// worker thinks, and right here. Offline, a plain fetch for the manifest goes
// to the network, fails, and logs ERR_INTERNET_DISCONNECTED to the console on
// every single launch — noise on the one screen where a real error needs to
// be visible, in service of re-reading a list of four icon paths that has not
// changed since the manifest was written and is already cached anyway.
const res = (typeof caches !== "undefined" && await caches.match(MANIFEST_URL))
|| await fetch(MANIFEST_URL);
if (!res || !res.ok) return [];
const manifest = await res.json();
return (manifest.icons || [])
.map(icon => icon && icon.src)
.filter(Boolean)
.map(src => new URL(src, location.origin).href);
} catch {
return [];
}
};
const registerWorker = async () => {
if (!("serviceWorker" in navigator)) return;
const shell = [
location.pathname,
MANIFEST_URL,
...await manifestIcons(),
// Stylesheets, plus the icons the document points at itself — iOS reads
// none of the manifest's icons and only <link rel="apple-touch-icon">, so
// an installed iPhone launched with no signal needs this one specifically.
...[...document.querySelectorAll(
'link[rel="stylesheet"][href], link[rel="apple-touch-icon"][href], link[rel="icon"][href]'
)].map(el => el.href),
...[...document.querySelectorAll("script[src]")].map(el => el.src),
].filter(url => new URL(url, location.origin).origin === location.origin);
try {
const reg = await navigator.serviceWorker.register("/casino/sw.js", { scope: "/casino/" });
// Whichever worker this registration has, at whatever stage it has reached.
//
// This used to fall back on navigator.serviceWorker.ready, which resolves
// with the registration *controlling this page* — and so never resolves at
// all on a page outside the worker's scope. That is not hypothetical: it is
// exactly what /Casino/TexasHoldem did before the redirect in Program.cs,
// and the symptom was the worst kind. The registration succeeded, the worker
// activated, nothing was logged, and the cache stayed completely empty — a
// dealer who had "loaded the tool for tonight" had loaded nothing.
const worker = reg.installing || reg.waiting || reg.active;
if (!worker) return;
const send = () => worker.postMessage({ type: "precache", urls: shell });
if (worker.state === "activated") send();
else worker.addEventListener("statechange", () => {
if (worker.state === "activated") send();
});
} catch {
// No worker means no offline, and nothing else. Every other part of the
// tool works exactly as it did, so this is not worth a toast at a table.
}
};
// After load, not during it: registering competes with the page's own scripts for
// the network, and the first paint matters more than the second visit does.
if (document.readyState === "complete") registerWorker();
else window.addEventListener("load", registerWorker, { once: true });
});
@@ -86,6 +86,21 @@ const Money = {
return this.fromCents(Math.ceil(cents / step) * step);
},
// And down, for money coming *out* of a pot rather than going into it.
//
// The rake is the one amount in this tool that leaves the table, so the direction
// of the rounding is a decision about whose money it is. Down: a house that can't
// make 40c out of its chips takes nothing rather than a dollar, and the players
// keep the difference. Being wrong in the players' favour is the only way to be
// wrong about this that nobody has to argue about.
downToChip(amount, legend) {
const step = this.chipStepCents(legend);
if (step <= 0) return this.round(amount);
const cents = this.toCents(amount);
if (cents <= 0) return 0;
return this.fromCents(Math.floor(cents / step) * step);
},
chipBreakdown(amount, legend) {
let cents = this.toCents(amount);
if (cents <= 0) return { chips: [], remainder: 0 };
@@ -141,6 +156,29 @@ const Money = {
return type === "text" && String(field.inputMode || "").toLowerCase() === "decimal";
},
// Would a wheel over this field change what it says?
//
// True only for a focused `<input type="number">`, which a browser treats as a
// spinbutton and lets the wheel drive. Every other way of driving one is deliberate
// — the arrow keys, the stepper arrows, typing — and all of them are left alone.
// Scrolling is the one nobody means: the dealer is reaching for another panel and
// the cursor happens to still be over a field they touched a moment ago.
//
// A predicate rather than a listener so the rule can be tested without a browser,
// the same way selectsOnFocus is. main.js does the blurring.
wheelWouldEdit(field, activeElement) {
// The tag check is belt and braces and known to be so: nothing but an <input>
// carries type="number", so the line below already excludes every textarea,
// select and button on the page. Deleting it passes every test in this file,
// which was checked rather than assumed. It stays for symmetry with
// selectsOnFocus above, not because it is load-bearing.
if (!field || field.tagName !== "INPUT") return false;
if (String(field.type || "").toLowerCase() !== "number") return false;
// An unfocused spinbutton ignores the wheel, so there is nothing to defend and
// blurring something that isn't focused would be a no-op with a side effect.
return activeElement === field;
},
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
// glance, and the sign belongs out front where the eye lands first.
formatSigned(amount) {
@@ -76,6 +76,39 @@ const PotWizard = {
},
renderStep() {
this.renderStepInto();
this.markOverflow();
},
// Say when the list runs on past the bottom of the dialog.
//
// The body scrolls and the footer stays put, which is the right shape and was as far
// as it went: nothing on screen said there was anything below. Measured on a Galaxy
// S8, a three-way pot showed two names and cut the third off on a row boundary, just
// under a hairline that reads as the end of the list. A dealer would have ticked the
// two they could see. On the dialog that pays the table, a list that silently
// continues is not a small thing.
markOverflow() {
const body = document.getElementById("potWizardBody");
const modal = document.getElementById("potWizardModal");
if (!body || !modal) return;
const update = () => {
const below = body.scrollHeight - body.clientHeight - body.scrollTop;
modal.classList.toggle("potWizardMoreBelow", below > 1);
};
if (!this.watchingOverflow) {
body.addEventListener("scroll", update, { passive: true });
window.addEventListener("resize", update);
this.watchingOverflow = true;
}
update();
// Again once the browser has laid the step out — on the first open the body has
// no height yet, and an answer taken then is always "nothing below".
requestAnimationFrame(update);
},
renderStepInto() {
// Auto-resolve any round where only one player is left standing — nothing to ask.
let contenders = PotCalculator.getContenders(this.entries);
while (contenders.length === 1) {
@@ -87,6 +120,11 @@ const PotWizard = {
const body = document.getElementById("potWizardBody");
body.innerHTML = "";
// The footer is a sibling of the body, outside the scroll — see the note in
// TexasHoldem.cshtml. Cleared alongside it so a step can never inherit the
// previous step's buttons.
const footer = this.footer();
footer.innerHTML = "";
if (this.log.length === 0) body.appendChild(this.renderBreakdown());
body.appendChild(this.renderLog());
@@ -119,13 +157,25 @@ const PotWizard = {
const round = document.createElement("div");
round.className = "potWizardRound";
const showdown = this.readShowdown(contenders);
const decided = showdown && showdown.complete ? new Set(showdown.winners) : null;
const handById = new Map((showdown ? showdown.players : []).map(p => [p.id, p.hand]));
const prompt = document.createElement("p");
prompt.className = "potWizardPrompt";
prompt.innerHTML = this.log.length === 0
? `Who won the hand? <strong>${Money.format(remaining)}</strong> is in play.`
: `That side pot still leaves <strong>${Money.format(remaining)}</strong> unassigned — who's next best?`;
prompt.innerHTML = decided
// It has read the hands, so it stops asking a question it knows the answer
// to. The list stays — the dealer confirms, and a hand entered wrong is
// corrected by unticking rather than by starting over.
? `<strong>${Money.format(remaining)}</strong> in play.`
: this.log.length === 0
? `Who won the hand? <strong>${Money.format(remaining)}</strong> is in play.`
: `That side pot still leaves <strong>${Money.format(remaining)}</strong> unassigned — who's next best?`;
round.appendChild(prompt);
const verdict = this.renderVerdict(showdown, contenders);
if (verdict) round.appendChild(verdict);
const choices = document.createElement("div");
choices.className = "potWizardChoices";
contenders.forEach(c => {
@@ -135,6 +185,10 @@ const PotWizard = {
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.value = c.id;
if (decided && decided.has(c.id)) {
checkbox.checked = true;
label.classList.add("chosen");
}
// The row is the target, not the box — so the row has to look chosen too.
checkbox.addEventListener("change", () => {
label.classList.toggle("chosen", checkbox.checked);
@@ -149,11 +203,25 @@ const PotWizard = {
name.textContent = this.nameById.get(c.id);
text.appendChild(name);
// How this hand was read, under the name — on every row rather than only
// the winning one, because a dealer checking the tool's answer is reading
// the losers' hands, and a row that says nothing is a row they have to work
// out for themselves. It takes the line the ceiling used to have; the
// ceiling moves out beside the name, which is where the phone layout had
// already put it to save exactly this line.
const read = handById.get(c.id);
if (read) {
const ranked = document.createElement("span");
ranked.className = "potWizardChoiceHand";
ranked.textContent = HandRank.describe(read);
text.appendChild(ranked);
}
label.appendChild(text);
const upTo = document.createElement("span");
upTo.className = "potWizardChoiceUpTo";
upTo.textContent = `up to ${Money.format(c.remaining)}`;
text.appendChild(upTo);
label.appendChild(text);
label.appendChild(upTo);
// Cards sit apart from the name rather than inside it, so a dealer reading
// a row across the table sees the hand, not a sentence.
@@ -182,7 +250,11 @@ const PotWizard = {
const confirmBtn = document.createElement("button");
confirmBtn.className = "holdemBtn holdemBtnAccent";
confirmBtn.textContent = "Confirm Winner(s)";
confirmBtn.textContent = decided
? (showdown.winners.length === 1
? `Pay ${this.nameById.get(showdown.winners[0])}`
: "Split the pot")
: "Confirm Winner(s)";
confirmBtn.addEventListener("click", () => {
const selected = Array.from(choices.querySelectorAll("input:checked")).map(i => i.value);
if (selected.length === 0) return;
@@ -191,9 +263,91 @@ const PotWizard = {
this.entries = result.updatedEntries;
this.renderStep();
});
round.appendChild(confirmBtn);
body.appendChild(round);
footer.appendChild(confirmBtn);
},
// How the tool reads the hands still in for this round of the pot.
//
// Asked per round rather than once for the whole hand, because a side pot has its
// own contenders: the player who won the main pot is out of the question for the
// next one, and the best hand among who is left is a different answer.
//
// Only when the dealer asked to track the board — everything here is off by default
// and the wizard behaves exactly as it always has.
readShowdown(contenders) {
if (!this.state.trackBoard || typeof HandRank === "undefined") return null;
return HandRank.showdown(this.state.board, contenders.map(c => ({
id: c.id,
cards: this.cardsById.get(c.id) || [],
})));
},
// What the winners would actually be paid if this round were confirmed now.
//
// Worked out from the same function that does the paying rather than from a second
// one that agrees with it today — a preview that can drift from the payout is worse
// than no preview, because it is the number the dealer says out loud.
previewPayout(winnerIds) {
try {
const result = PotCalculator.resolveRound(this.entries, this.orderFromButton(winnerIds));
return result.perWinner || [];
} catch {
return [];
}
},
// "Alexandra wins $25 with a full house, kings full of threes."
//
// The whole point of the feature, in one line: who won and how much. It goes above
// the list rather than replacing it, because the dealer still confirms — the tool
// reads the cards, a person still says the pot is good.
renderVerdict(showdown, contenders) {
if (!showdown) return null;
const line = document.createElement("p");
line.className = "potWizardVerdict";
if (!showdown.boardComplete) {
line.classList.add("potWizardVerdictUnsure");
line.textContent = "The board isn't all five cards yet, so pick the winner yourself — or fill it in on the felt and this will read it.";
return line;
}
if (showdown.duplicated) {
line.classList.add("potWizardVerdictUnsure");
line.textContent = "The same card is in two places, so this can't be read. Check the cards on the felt.";
return line;
}
if (!showdown.complete) {
const missing = showdown.unknown.map(id => this.nameById.get(id)).filter(Boolean);
line.classList.add("potWizardVerdictUnsure");
line.textContent = missing.length
? `${this.listOf(missing)} ${missing.length === 1 ? "hasn't" : "haven't"} turned over, so who won is still yours to say.`
: "Not enough cards to read this one.";
return line;
}
const paid = this.previewPayout(showdown.winners);
const amount = paid.length ? Money.format(paid[0].amount) : "";
const names = showdown.winners.map(id => this.nameById.get(id)).filter(Boolean);
const hand = showdown.players.find(p => p.id === showdown.winners[0]);
const described = HandRank.phrase(hand && hand.hand);
line.classList.add("potWizardVerdictSure");
line.textContent = names.length === 1
? `${names[0]} wins ${amount} with ${described}.`
: `${this.listOf(names)} split it, ${amount} each, with ${described}.`;
return line;
},
listOf(names) {
if (names.length <= 1) return names[0] || "";
return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
},
// The row of buttons that never scrolls away.
footer() {
return document.getElementById("potWizardFooter");
},
recordRound(result) {
@@ -232,6 +386,24 @@ const PotWizard = {
return wrap;
},
// What the house takes out of this pot, and what is left to push.
//
// Worked out here rather than at the end so the dealer sees it before anything
// moves: the summary is the last chance to disagree with the arithmetic, and a
// rake that only appeared in the hand log afterwards would be the tool taking
// money quietly.
rakeSplit() {
const gross = Array.from(this.totals.entries()).map(([id, amount]) => ({
id,
label: this.playerLabelById.get(id),
amount,
}));
const pot = gross.reduce((sum, p) => sum + p.amount, 0);
const sawFlop = (this.state.street || "preflop") !== "preflop";
const rake = HoldemState.rakeOn(this.state, pot, sawFlop);
return { pot, sawFlop, ...HoldemState.applyRake(gross, rake) };
},
renderSummary() {
const wrap = document.createElement("div");
wrap.className = "potWizardSummary";
@@ -240,6 +412,20 @@ const PotWizard = {
heading.textContent = "Final Payouts";
wrap.appendChild(heading);
const split = this.rakeSplit();
const rakeSet = Number((this.state.rake || {}).percent) > 0;
if (rakeSet && this.totals.size > 0) {
const line = document.createElement("p");
line.className = "potWizardRake";
const cap = Number(this.state.rake.cap) > 0 ? `, capped at ${Money.format(this.state.rake.cap)}` : "";
line.textContent = split.rake > 0
? `House takes ${Money.format(split.rake)} of ${Money.format(split.pot)} — ${this.state.rake.percent}%${cap}.`
: split.sawFlop
? `No rake on this one — ${this.state.rake.percent}% of ${Money.format(split.pot)} doesn't make a chip the table has.`
: "No flop, no drop — nothing taken from a pot that ended before the flop.";
wrap.appendChild(line);
}
if (this.totals.size === 0) {
const empty = document.createElement("p");
empty.className = "fieldHint";
@@ -248,7 +434,7 @@ const PotWizard = {
} else {
const legend = (this.state && this.state.chipLegend) || [];
const list = document.createElement("ul");
for (const [id, amount] of this.totals.entries()) {
for (const { id, amount } of split.payouts) {
const li = document.createElement("li");
li.textContent = `${this.playerLabelById.get(id)} — ${Money.format(amount)}`;
@@ -275,13 +461,12 @@ const PotWizard = {
applyBtn.className = "holdemBtn holdemBtnAccent";
applyBtn.textContent = "Start Next Hand";
applyBtn.addEventListener("click", () => {
const payouts = Array.from(this.totals.entries()).map(([id, amount]) => ({
id,
label: this.playerLabelById.get(id),
amount,
}));
// Recomputed rather than reused from the render: the same numbers, but
// taken at the moment the dealer commits rather than when they last
// looked at the screen.
const paid = this.rakeSplit();
this.close();
this.onFinished(payouts);
this.onFinished(paid.payouts, { rake: paid.rake, pot: paid.pot });
});
actions.appendChild(applyBtn);
@@ -300,12 +485,10 @@ const PotWizard = {
const note = document.createElement("p");
note.className = "fieldHint potWizardPendingNote";
note.textContent = "Nothing has been paid out yet. Start Next Hand pushes the pot and logs the hand.";
wrap.appendChild(actions);
wrap.appendChild(note);
return wrap;
}
wrap.appendChild(actions);
this.footer().appendChild(actions);
return wrap;
},
};
File diff suppressed because it is too large Load Diff
@@ -47,6 +47,27 @@ const SeatMenu = {
run: () => this.hooks.toggleDealtIn(index),
});
// An empty chair with people waiting: the dealer taps the chair somebody is
// about to sit in, which is what their hand is doing anyway. Only the front of
// the queue is offered, for the same reason the panel only offers them — a list
// that isn't kept in order is just a pile of names.
const waiting = (this.state.waitingList || [])[0];
const emptyChair = !HoldemState.seatInPlay(player);
if (waiting && emptyChair) {
actions.push({
label: `Seat ${waiting.name}`,
className: "menuAccent",
run: () => this.hooks.seatWaiting(waiting.id, index),
});
}
// Leaving for the night, which is a different thing from sitting out a hand:
// the chips go back over the rail and the chair opens. Offered only for a seat
// that has somebody in it, since an empty chair has nobody to pay.
if (HoldemState.seatInPlay(player)) {
actions.push({ label: "Cash out…", run: () => this.hooks.cashOut(index) });
}
if (!this.state.dedicatedDealer && index !== this.state.dealerSeatIndex) {
actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) });
}
+612 -15
View File
@@ -58,6 +58,19 @@ const HoldemState = {
bigBlind: 2,
defaultStack: null,
street: "preflop",
// The community cards, in the order they hit the felt: three, then one,
// then one. Empty strings for the ones that haven't been turned yet, the
// same way a seat's two hole cards are stored.
board: ["", "", "", "", ""],
// Whether the dealer wants to be asked for them at all. Off is the tool as
// it has always been: no board on the felt, and the pot wizard asks who
// won. On, it reads the showdown and answers that question itself.
trackBoard: false,
// Whether to stop the screen sleeping while a hand is live. On, because a
// dealer who has to wake their phone between the flop and the turn is the
// single most common way this tool gets in the way of the game it is meant
// to be running. Only ever held while betting is open — see wakeLock.js.
keepScreenAwake: true,
currentBet: 0,
lastRaiseSize: 0,
actingSeatIndex: null,
@@ -100,6 +113,29 @@ const HoldemState = {
// the big blind rather than waiting for it. Cleared every hand: it's a
// decision about one deal, not a standing arrangement.
postingIn: [],
// How many straddles are on for the hand about to be dealt. A straddle is
// announced hand by hand, so this is cleared with every other one-deal
// decision rather than standing until somebody turns it off.
straddleCount: 0,
// What the house takes, if it takes anything. Percent zero is a game that
// doesn't rake, which is nearly all of them and the default.
rake: { percent: 0, cap: null, noFlopNoDrop: true },
// What it has taken so far tonight. A session total, not a per-hand one:
// it is the number the box is checked against at the end.
rakeTaken: 0,
// People who have taken their chips and gone. Kept for the night rather
// than forgotten with the seat: the box took their cash and paid it back
// out, and both halves have to stay on the record or the bank check reads
// as a shortfall for the rest of the evening.
cashedOut: [],
// People standing behind the chairs. A dealer at a full table keeps this
// list on a napkin; keeping it here means the tool can offer the next seat
// that opens to whoever has been waiting longest.
waitingList: [],
// Which seats actually put a straddle out when the hand was dealt, so the
// felt can badge them. Read off the deal rather than worked out again
// later, the same way the posted blinds are.
postedStraddles: [],
// Set when somebody leaves out of the blinds and the rotation has to hold
// to the seats to keep the big blind moving one player at a time. The
// button then sits on the chair they vacated, and no small blind is posted.
@@ -164,13 +200,18 @@ const HoldemState = {
return this.createDefault();
},
load() {
let raw = null;
try {
raw = localStorage.getItem(this.STORAGE_KEY);
if (!raw) return this.createDefault();
const parsed = JSON.parse(raw);
if (!parsed || !Array.isArray(parsed.seats)) return this.failedLoad(raw);
// Everything a table has to be put through before it can be trusted, whichever
// direction it arrived from.
//
// Pulled out of load() so a table read from a file goes through exactly the same
// checks as one read from storage. A second, looser path for imports is how a
// format grows two meanings of the same field.
//
// Returns null for anything that isn't a table at all; every other repair is
// silent and forward, the way a save from an older version is treated.
migrate(parsed) {
if (!parsed || !Array.isArray(parsed.seats)) return null;
{
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
if (!Array.isArray(parsed.handHistory)) parsed.handHistory = [];
if (typeof parsed.discarded !== "number" || !(parsed.discarded >= 0)) parsed.discarded = 0;
@@ -188,6 +229,17 @@ const HoldemState = {
// A table saved before the dead button existed has neither flag. False is
// the right guess for both: it means the rotation carries on the ordinary
// way, which is what that table was already doing.
// A table saved before the board existed has no board and was not tracking
// one. Off is also the right guess for a dealer who never asked for it.
parsed.trackBoard = parsed.trackBoard === true;
parsed.board = this.normaliseBoard(parsed.board);
// Note the direction: `!== false` rather than `=== true`. A table saved
// before this setting existed has no opinion about it, and the useful guess
// for a dealer who has never been asked is the helpful one. Only somebody
// who has actually turned it off gets it off.
parsed.keepScreenAwake = parsed.keepScreenAwake !== false;
parsed.deadButton = parsed.deadButton === true;
parsed.deadSmallBlind = parsed.deadSmallBlind === true;
// An old save has no record of who was dealt last hand. Empty is the
@@ -196,6 +248,41 @@ const HoldemState = {
// asking the whole table to post.
if (!Array.isArray(parsed.dealtLastHand)) parsed.dealtLastHand = [];
if (!Array.isArray(parsed.postingIn)) parsed.postingIn = [];
// None on is the right reading of a save from before straddles existed:
// whatever is in front of those players is already in their committed
// chips, and nothing is owed on top of it.
if (!(Number(parsed.straddleCount) > 0)) parsed.straddleCount = 0;
// A table saved before the house took anything takes nothing, which is
// also the right answer for one that never will.
const rake = parsed.rake && typeof parsed.rake === "object" ? parsed.rake : {};
parsed.rake = {
percent: Math.min(100, Math.max(0, Number(rake.percent) || 0)),
cap: Number(rake.cap) > 0 ? Money.round(Number(rake.cap)) : null,
noFlopNoDrop: rake.noFlopNoDrop !== false,
};
parsed.rakeTaken = Math.max(0, Money.round(Number(parsed.rakeTaken) || 0));
parsed.waitingList = Array.isArray(parsed.waitingList)
? parsed.waitingList
.map(row => (typeof row === "string" ? { id: null, name: row } : row))
.filter(row => row && String(row.name || "").trim())
.map(row => ({
id: row.id || this.makeId("waiting"),
name: String(row.name).trim().slice(0, 24),
addedAt: Number(row.addedAt) || 0,
}))
: [];
parsed.cashedOut = Array.isArray(parsed.cashedOut)
? parsed.cashedOut
.filter(row => row && row.id)
.map(row => ({
id: row.id,
name: String(row.name || "").slice(0, 40),
buyIns: Math.max(0, Money.round(Number(row.buyIns) || 0)),
chips: Math.max(0, Money.round(Number(row.chips) || 0)),
at: Number(row.at) || 0,
}))
: [];
if (!Array.isArray(parsed.postedStraddles)) parsed.postedStraddles = [];
// A table saved before dealing and betting were separate has no flag, and
// guessing false would show "deal the hand" over a hand already in
@@ -215,12 +302,127 @@ const HoldemState = {
});
return parsed;
}
},
load() {
let raw = null;
try {
raw = localStorage.getItem(this.STORAGE_KEY);
if (!raw) return this.createDefault();
const migrated = this.migrate(JSON.parse(raw));
if (!migrated) return this.failedLoad(raw);
return migrated;
} catch {
// Malformed JSON — a truncated write, another tab, a browser hiccup.
return this.failedLoad(raw);
}
},
// ---- Taking the table off this device ------------------------------------
//
// A night lives in one browser's localStorage and nowhere else. A dealer whose
// phone dies, or who wants to carry on from the tablet after the break, loses the
// stacks, the buy-ins, where the button is and the whole hand log — and the CSV
// export can't bring any of that back, because it records what happened rather
// than where the table stands.
//
// So the table can be written out as a file and read back in. Same shape that goes
// into storage, wrapped in enough to recognise it later.
// ---- Asking the browser to actually keep it -------------------------------
//
// "The night lives in this browser and nowhere else" is truer than it sounds.
// localStorage is *evictable*: when a device runs short, the browser clears
// site data to make room, and it starts with the sites it thinks nobody cares
// about. Safari goes further and deletes script-created storage after seven days
// without a visit — so a table saved at last month's game can simply not be there
// at this one, with nothing having gone wrong and nobody having deleted anything.
//
// `persist()` is the way to say this data is not that. Granted, the storage can
// only be cleared by the person whose browser it is. Chromium and Safari decide
// silently on how much the site has been used and whether it's installed; Firefox
// asks. All three want the request to come from something the dealer did, which is
// why this fires on the first hand dealt rather than on page load.
supportsDurableStorage() {
return typeof navigator !== "undefined"
&& !!navigator.storage
&& typeof navigator.storage.persist === "function";
},
async isDurable() {
if (!this.supportsDurableStorage()) return false;
try {
return await navigator.storage.persisted();
} catch {
return false;
}
},
// Returns whether the browser said yes. A no is not a failure worth reporting at a
// table — the tool behaves identically either way, and the only honest difference
// is how confident the Save & reset panel is allowed to sound.
async requestDurable() {
if (!this.supportsDurableStorage()) return false;
try {
if (await navigator.storage.persisted()) return true;
return await navigator.storage.persist();
} catch {
return false;
}
},
BACKUP_KIND: "casino.holdem.table",
BACKUP_VERSION: 1,
toBackup(state) {
return JSON.stringify({
kind: this.BACKUP_KIND,
version: this.BACKUP_VERSION,
savedAt: new Date().toISOString(),
table: state,
}, null, 2);
},
// Reads a file back. Returns { table } on success, or { error } with something a
// dealer can act on — never a half-loaded table, because the failure mode there is
// a night that looks restored and isn't.
fromBackup(text) {
let parsed;
try {
parsed = JSON.parse(text);
} catch {
return { error: "That file isn't a saved table — it couldn't be read as JSON." };
}
if (!parsed || typeof parsed !== "object") {
return { error: "That file isn't a saved table." };
}
// A bare table, from someone who copied the storage value out by hand, is
// still a table. The envelope is a convenience, not a password.
const table = parsed.kind === this.BACKUP_KIND || parsed.table ? parsed.table : parsed;
if (parsed.kind && parsed.kind !== this.BACKUP_KIND) {
return { error: "That file is a save from something else, not a Hold'em table." };
}
const migrated = this.migrate(table);
if (!migrated) {
return { error: "That file doesn't have a table in it — no seats were found." };
}
if (parsed.version > this.BACKUP_VERSION) {
// Newer than this build understands. Loading it anyway risks reading a
// field that has changed meaning, and the dealer would find out from the
// money, so say so instead.
return { error: "That table was saved by a newer version of this tool. Update, then load it." };
}
return { table: migrated, savedAt: parsed.savedAt || null };
},
// What the file is called. Dated, because a dealer who saves twice in a night
// should not have to guess which is which.
backupFileName(now = new Date()) {
const pad = n => String(n).padStart(2, "0");
return `holdem-table-${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +
`-${pad(now.getHours())}${pad(now.getMinutes())}.json`;
},
// True once a save has failed. The UI reads this to keep saying so, because a
// toast is a moment and this is a fact about the rest of the night.
saveFailed: false,
@@ -565,6 +767,11 @@ const HoldemState = {
// Posting to come in is a decision about one deal. Carrying it forward would
// charge somebody an extra big blind every hand for the rest of the night.
state.postingIn = [];
// So is a straddle. It is announced hand by hand at a table, and a straddle
// that stayed on would take chips off somebody who never said the word.
state.straddleCount = 0;
state.postedStraddles = [];
this.clearBoard(state);
state.seats.forEach(p => {
p.folded = false;
p.cards = ["", ""];
@@ -577,6 +784,21 @@ const HoldemState = {
this.ensureButtonOnPlayer(state);
},
// Exactly five slots of either a two-character card or an empty string, whatever
// arrived. A board read off a file is the one that has been somewhere this tool
// cannot see.
normaliseBoard(board) {
const source = Array.isArray(board) ? board : [];
return Array.from({ length: 5 }, (_, i) => {
const card = typeof source[i] === "string" ? source[i].trim().toUpperCase() : "";
return /^[23456789TJQKA][SHDC]$/.test(card) ? card : "";
});
},
clearBoard(state) {
state.board = ["", "", "", "", ""];
},
// Appends a resolved-pot record to the running session log a dealer can point to
// for chip-count disputes, then advances the hand counter. `payouts` is
// [{ id, label, amount }, ...] — who got paid what out of this hand's pot(s).
@@ -584,11 +806,15 @@ const HoldemState = {
// Must be called before startNextHand() clears the seats: the per-player `ledger`
// it stores alongside the payouts is what session standings are summed from later,
// and it needs this hand's contributions while they're still on the table.
recordHand(state, payouts) {
recordHand(state, payouts, rake = 0) {
if (typeof state.handNumber !== "number") state.handNumber = 1;
if (!Array.isArray(state.handHistory)) state.handHistory = [];
const pot = payouts.reduce((sum, p) => sum + p.amount, 0);
// The pot as it was, house cut included — the number the table saw in the
// middle. What each player took is separately recorded and is net of it, so
// the two together say where every chip went.
const taken = Money.round(Math.max(0, Number(rake) || 0));
const pot = Money.round(payouts.reduce((sum, p) => sum + p.amount, 0) + taken);
const wonById = new Map(payouts.map(p => [p.id, p.amount]));
const ledger = state.seats
.map((player, index) => ({
@@ -603,6 +829,7 @@ const HoldemState = {
handNumber: state.handNumber,
timestamp: Date.now(),
pot,
rake: taken,
payouts,
ledger,
});
@@ -638,6 +865,15 @@ const HoldemState = {
// Cash bought in and chips currently in front don't come from the hand log at
// all, so they're read off the seat rather than summed — a rebuy isn't a hand.
const seatById = new Map(state.seats.map(p => [p.id, p]));
// And for anyone who has gone home, off the record their cash-out left behind.
// Without this they read as a player who bought in for nothing and is holding
// nothing, in the very list the dealer settles the night from.
const goneById = new Map((state.cashedOut || []).map(row => [row.id, row]));
goneById.forEach((row, id) => {
if (totals.has(id)) return;
totals.set(id, { id, name: row.name, in: 0, won: 0 });
});
// Anyone who has money in the game belongs in the settle-up, whether or not
// they've been in a logged hand yet. Building the list from the hand log alone
@@ -649,7 +885,7 @@ const HoldemState = {
if (totals.has(player.id)) return;
const hasMoneyInPlay =
(Number(player.buyIns) || 0) > 0 ||
(Number(player.stack) || 0) > 0 ||
this.chipsHeldBy(state, player) > 0 ||
(Number(player.amountOverride) || 0) > 0 ||
(Number(player.committed) || 0) > 0;
if (!hasMoneyInPlay) return;
@@ -659,8 +895,16 @@ const HoldemState = {
return Array.from(totals.values())
.map(t => {
const seat = seatById.get(t.id);
const boughtIn = seat ? Money.round(Number(seat.buyIns) || 0) : 0;
const chipsNow = seat ? Money.round(Number(seat.stack) || 0) : 0;
const gone = goneById.get(t.id);
const boughtIn = seat
? Money.round(Number(seat.buyIns) || 0)
: (gone ? gone.buyIns : 0);
// What a departed player is "holding" is what the box handed them on
// the way out. It is no longer on the table, so it is marked as paid
// rather than counted again at the end of the night.
const chipsNow = seat
? this.chipsHeldBy(state, seat)
: (gone ? gone.chips : 0);
// Chips already pushed into the hand in progress are still this
// player's money until it's won. Leaving them out makes the cash
// position disagree with the hand log for as long as a hand is open —
@@ -691,6 +935,10 @@ const HoldemState = {
// a $340 stack read as "$140" at the exact moment cash changed
// hands.
chipsInFront: Money.round(chipsNow + inFrontOfThem),
// Set once they have taken their chips and left, so the panel and
// the export can say so rather than showing a player still sitting
// there with a stack.
cashedOut: !!gone && !seat,
cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn),
notes,
};
@@ -723,12 +971,351 @@ const HoldemState = {
//
// Chips in play counts everything a player could hand back: what's in front of
// them and what's in the middle waiting to be won.
// ---- The people standing behind the chairs ----
//
// A full home game always has a couple of them, and the list lives on a napkin or
// in the dealer's head — which is where the argument comes from an hour later about
// who said they were next. Kept in order of arrival, because that is the only
// ordering anybody has ever agreed to.
addToWaitingList(state, name) {
const trimmed = String(name || "").trim().slice(0, 24);
if (!trimmed) return null;
if (!Array.isArray(state.waitingList)) state.waitingList = [];
const entry = { id: this.makeId("waiting"), name: trimmed, addedAt: Date.now() };
state.waitingList.push(entry);
return entry;
},
removeFromWaitingList(state, id) {
if (!Array.isArray(state.waitingList)) return null;
const index = state.waitingList.findIndex(row => row.id === id);
if (index < 0) return null;
return state.waitingList.splice(index, 1)[0];
},
// Chairs with nobody in them, in seat order. What "a seat has opened" means, and
// the same question the deal prompt answers when it counts the players.
openSeats(state) {
return state.seats
.map((player, index) => ({ player, index }))
.filter(({ player }) => !this.seatInPlay(player))
.map(({ index }) => index);
},
// Sit the next person down. Their name goes on the chair and nothing else does:
// they haven't bought in yet, and guessing a stack for them would put chips on the
// table that nobody has paid for — which is the one thing the bank check exists to
// catch.
seatFromWaitingList(state, id, index) {
const seat = state.seats[index];
if (!seat) return { ok: false, reason: "There's no such seat." };
if (this.seatInPlay(seat)) {
return { ok: false, reason: `${this.displayName(seat, index)} is already in that seat.` };
}
const entry = this.removeFromWaitingList(state, id);
if (!entry) return { ok: false, reason: "They aren't on the list any more." };
seat.name = entry.name;
// Not dealt in and holding nothing: the next thing that happens is the dealer
// taking their money, and Add chips is what says so.
seat.inHand = false;
return { ok: true, name: entry.name, index };
},
// ---- Somebody leaving with their chips ----
//
// The most ordinary thing that happens at a home game after midnight, and the tool
// had nothing for it. A dealer's only way to free the chair was to wipe the seat by
// hand, and then the bank check — which adds up cash taken against chips on the
// table — declared the departing player's whole stack missing: "someone has paid
// for chips they haven't been given", for the rest of the night. Wiping their
// buy-in as well silenced it, at the price of losing their night from the standings
// and the file the dealer hands over.
//
// Cashing out is two facts, and both have to survive the empty chair: the box took
// their cash, and the box paid their chips back out.
// Can this seat be cashed out right now?
canCashOut(state, index) {
const player = state.seats[index];
if (!player) return { ok: false, reason: "There's nobody in that seat." };
const inPot = Money.round((Number(player.committed) || 0) + (Number(player.amountOverride) || 0));
if (inPot > 0) {
return {
ok: false,
// Paying someone out with money still in the middle would either take
// chips out of a pot other people can win, or hand them chips twice.
reason: `${this.displayName(player, index)} has ${Money.format(inPot)} in the pot. Finish the hand first.`,
};
}
return { ok: true };
},
// What the dealer counts out, and what the seat's night comes to.
cashOutPreview(state, index) {
const player = state.seats[index];
if (!player) return null;
// Betting owns the answer to "what is this seat holding" — including the rule
// that a seat which has never been given a stack is holding the table's
// starting one. Asking it rather than working it out again here: the first
// draft of this did, read `Number(null)` as a perfectly good zero, and offered
// to pay a player nothing.
const chips = Money.round(Math.max(0, Betting.stackOf(state, player)));
const boughtIn = Money.round(Math.max(0, Number(player.buyIns) || 0));
return {
index,
name: this.displayName(player, index),
chips,
boughtIn,
// Their result for the night, in the direction a banker says it: positive
// means the box is paying out more than it took from them.
net: Money.round(chips - boughtIn),
breakdown: Money.formatChipBreakdown(chips, state.chipLegend),
};
},
// Hand them their chips and free the chair, keeping both halves on the record.
cashOut(state, index) {
const allowed = this.canCashOut(state, index);
if (!allowed.ok) return allowed;
const preview = this.cashOutPreview(state, index);
const leavingId = state.seats[index].id;
if (!Array.isArray(state.cashedOut)) state.cashedOut = [];
state.cashedOut.push({
id: leavingId,
name: preview.name,
buyIns: preview.boughtIn,
chips: preview.chips,
at: Date.now(),
});
// The chair opens for the next player. A fresh id, because the person who had
// it has gone and their night is now filed under the old one — reusing it would
// merge two people's results the moment somebody new sits down.
const seat = this.makePlayer();
state.seats[index] = seat;
// Anyone owed a hand by this seat is no longer owed one.
state.postingIn = (state.postingIn || []).filter(id => id !== leavingId);
return { ok: true, ...preview };
},
// ---- The house's cut ----
//
// A game that takes a rake or a time charge has chips leaving the table every hand,
// and until now the bank check called them missing: it added up the buy-ins,
// added up the chips on the felt, and told the dealer somebody had been paid for
// chips they never got. The house's cut is the one thing on this table that is
// supposed to go and not come back, so it is counted rather than mourned.
//
// Percentage with a cap, which is what nearly every room and home game runs. Zero
// percent is a game that doesn't rake, which is the default and the common case.
// What the house takes from a pot of this size.
//
// "No flop, no drop" is the rule almost everyone plays: a hand that ends before the
// flop pays nothing, because the players never got a hand out of it. It is on by
// default and can be turned off for a house that rakes every pot.
rakeOn(state, pot, sawFlop = true) {
const rake = state.rake || {};
const percent = Number(rake.percent) || 0;
const amount = Money.round(Number(pot) || 0);
if (percent <= 0 || amount <= 0) return 0;
if (rake.noFlopNoDrop !== false && !sawFlop) return 0;
const uncapped = (amount * percent) / 100;
const cap = Number(rake.cap);
const capped = cap > 0 ? Math.min(uncapped, cap) : uncapped;
// Never more than the pot, however the numbers were typed in.
return Money.downToChip(Math.min(capped, amount), state.chipLegend);
},
// The same payouts with the house's cut taken out of them.
//
// Off the top of the whole pot, then shared out in proportion — which for the hand
// a dealer actually deals, with one winner, is just "winner gets the pot less the
// rake". Split pots take it proportionally, and whatever the rounding leaves over
// goes back to the winners in turn rather than to the house: the house asked for a
// number, and it gets that number, not that number plus dust.
applyRake(payouts, rake) {
const total = payouts.reduce((sum, p) => sum + p.amount, 0);
const taken = Money.round(Math.min(Math.max(0, rake), total));
if (taken <= 0) return { payouts: payouts.map(p => ({ ...p })), rake: 0 };
const kept = Money.toCents(total) - Money.toCents(taken);
const shares = payouts.map(p => {
const exact = (Money.toCents(p.amount) * kept) / Money.toCents(total);
return { payout: p, cents: Math.floor(exact) };
});
// Floor everybody, then hand the leftover cents out one at a time, biggest
// share first — the same shape as the odd-chip rule the split calculator uses.
let spare = kept - shares.reduce((sum, s) => sum + s.cents, 0);
[...shares]
.sort((a, b) => b.payout.amount - a.payout.amount)
.forEach(share => { if (spare > 0) { share.cents += 1; spare -= 1; } });
return {
payouts: shares.map(share => ({ ...share.payout, amount: Money.fromCents(share.cents) })),
rake: taken,
};
},
// Is anybody actually in this chair? A seat with a name on it, chips in front of
// it, cash against it or a hand dealt to it. Empty chairs are not players, which
// matters the moment the table's starting stack starts standing in for a seat that
// has never been given one — every empty chair would otherwise look like somebody
// holding two hundred dollars.
seatInPlay(player) {
if (!player) return false;
return !!(player.name || "").trim() ||
(Number(player.buyIns) || 0) > 0 ||
(Number(player.stack) || 0) > 0 ||
(Number(player.committed) || 0) > 0 ||
(Number(player.amountOverride) || 0) > 0 ||
player.inHand === true;
},
// What a seat is holding, the way the felt and the betting see it — which includes
// the table's starting stack standing in for a seat that has never been given one.
//
// This is the number the money side of the tool was missing. "Starting stack" is
// offered in the setup panel as the ordinary way to open a table, and everything a
// dealer looks at — the puck, the bar, what a player can bet — reads it. The bank
// check and the standings read `seat.stack` directly instead, which is null for
// exactly those seats: measured on a three-handed table with a $200 starting stack
// and nobody's own stack set, the felt played for $600 while the bank check said
// "no chips in play, balanced" and the standings listed nobody at all.
chipsHeldBy(state, player) {
if (!this.seatInPlay(player)) return 0;
return Math.max(0, Money.round(Betting.stackOf(state, player)));
},
// What a chip colour is holding up.
//
// Removing one is not a change to a legend. computeContribution() prices a seat by
// running the legend over that seat's chip counts, so a colour that leaves the
// legend takes every chip counted in it off the table. Measured on a three-handed
// table counting real chips: deleting the $500 row took $22,500 of $26,874 with it,
// on one click of a 24px button, with nothing said and nothing shown.
//
// It does not come back, either. A colour added again is a new colour with a new
// id, while the counts stay keyed to the one that left — so "put it back" restores
// the row and none of the money.
//
// Hence this: the cost is answerable before the click rather than discovered at the
// end of the night, when the box is short and nobody knows why.
// The number here is the *difference*, asked by pricing every seat twice — once with
// the colour and once without — rather than by multiplying the chips by their value.
//
// Those two are not the same, and the multiplication is the wrong one, because
// computeContribution() does not always reach the chips. A seat with a typed amount
// is priced from that and its chip counts are ignored, so a colour it holds is worth
// nothing to it; and a seat whose chips all leave falls through to the table's base
// pot — the standing ante — rather than to zero, so it loses less than its chips
// were worth.
// Both would put a number in the confirmation that the table then failed to match,
// which is worse than no number at all — this is a warning whose whole job is to be
// the truth.
chipColorUsage(state, chipId) {
const chip = (state.chipLegend || []).find(entry => entry.id === chipId);
if (!chip) return { chip: null, total: 0, chips: 0, seats: [] };
const legend = state.chipLegend || [];
const without = legend.filter(entry => entry.id !== chipId);
const seats = state.seats
.map((player, index) => {
const before = this.computeContribution(player, legend, state.basePotAmount);
const after = this.computeContribution(player, without, state.basePotAmount);
return {
index,
name: this.displayName(player, index),
count: Number((player.chipCounts || {})[chipId]) || 0,
amount: Money.round(before - after),
};
})
// Signed, and not filtered to losses, because removing a colour can put the
// table *up*. A seat whose chips are worth less than the table's standing
// ante falls through to the ante once they are gone: found by the randomised
// check at 14 chips of a $1 colour against a $200 base pot, where deleting it
// moved the table by +186. Rare, but a warning that said "nothing changes"
// there would be as wrong as one that named the wrong number.
.filter(row => row.amount !== 0);
return {
chip,
seats,
chips: seats.reduce((sum, row) => sum + row.count, 0),
total: Money.round(seats.reduce((sum, row) => sum + row.amount, 0)),
};
},
// Take a colour out, and take its counts with it.
//
// Leaving them behind is what the old code did — the seats kept counts against an
// id no legend entry answers to any more, so the file carried money that nothing
// could price and nothing could show. Clearing them makes the saved table say what
// the screen says. The usage is measured before anything moves, so a caller can
// report what it cost.
removeChipColor(state, chipId) {
const usage = this.chipColorUsage(state, chipId);
if (!usage.chip) return usage;
state.chipLegend = (state.chipLegend || []).filter(entry => entry.id !== chipId);
state.seats.forEach(player => {
if (player.chipCounts && chipId in player.chipCounts) delete player.chipCounts[chipId];
});
return usage;
},
// Seats playing with chips the box has no cash against.
//
// Two kinds, and they are not the same problem. A seat holding the table's starting
// stack has never been given chips of its own — the dealer set "everyone starts
// with 200" and sat people down, which is the ordinary way to open a table and the
// reason this whole gap existed. A seat with its own stack and no buy-in is a
// rebuy somebody forgot to write down, and only the dealer knows what it was.
seatsOwingCash(state) {
return state.seats
.map((player, index) => ({ player, index }))
.filter(({ player }) => this.chipsHeldBy(state, player) > 0 &&
!((Number(player.buyIns) || 0) > 0))
.map(({ player, index }) => ({
index,
name: this.displayName(player, index),
chips: this.chipsHeldBy(state, player),
// True when they are holding the table's starting stack rather than one
// the dealer typed for them.
fromStartingStack: player.stack === null || player.stack === undefined ||
String(player.stack).trim() === "",
}));
},
// Write down the cash for the seats that are holding the table's starting stack.
// Only those: what somebody else's unrecorded rebuy was is a question only the
// dealer can answer, and guessing it would be the tool inventing money.
recordStartingBuyIns(state) {
const owed = this.seatsOwingCash(state).filter(row => row.fromStartingStack);
owed.forEach(row => { state.seats[row.index].buyIns = row.chips; });
return {
seats: owed.length,
total: Money.round(owed.reduce((sum, row) => sum + row.chips, 0)),
};
},
bankReconciliation(state) {
const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0);
const gone = Array.isArray(state.cashedOut) ? state.cashedOut : [];
// Cash the box took from people who have since left counts as taken: it is in
// the box, and forgetting it with the seat is what used to make a cash-out read
// as a shortfall all night.
const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0) +
gone.reduce((sum, row) => sum + Math.max(0, Number(row.buyIns) || 0), 0);
const chipsInPlay = state.seats.reduce(
(sum, p) =>
sum +
Math.max(0, Number(p.stack) || 0) +
this.chipsHeldBy(state, p) +
Math.max(0, Number(p.committed) || 0) +
Math.max(0, Number(p.amountOverride) || 0),
0
@@ -742,11 +1329,21 @@ const HoldemState = {
// hunting for a miscount that never happened.
const discarded = Math.max(0, Money.round(Number(state.discarded) || 0));
const difference = Money.round(chipsInPlay + discarded - cashIn);
// Chips the house has taken. Off the table for good, like the discards, and
// for a better reason: the box is supposed to have them.
const raked = Math.max(0, Money.round(Number(state.rakeTaken) || 0));
// Chips handed back over the rail. Off the table like the other two, and the
// one the dealer can point at when somebody asks where the money went.
const paidOut = Money.round(gone.reduce((sum, row) => sum + Math.max(0, Number(row.chips) || 0), 0));
const difference = Money.round(chipsInPlay + discarded + raked + paidOut - cashIn);
return {
cashIn: Money.round(cashIn),
chipsInPlay: Money.round(chipsInPlay),
discarded,
raked,
paidOut,
difference,
// Half a cent of float dust isn't a discrepancy worth alarming anyone over.
balanced: Math.abs(difference) <= Money.EPSILON,
@@ -0,0 +1,126 @@
// Keeps the screen on while a hand is live.
//
// The dealer's phone spends the night face-up on the felt with nine people round it,
// and the one thing it reliably does between the flop and the turn is dim, then lock.
// Every hand, all night. A dozen rounds of work have gone into making the next step
// readable at a glance, and none of it survives a black screen and a passcode with
// four people waiting to know whether the bet is called.
//
// Held only while betting is actually open. A phone left on the table through a break,
// a re-buy, or the twenty minutes where everyone is arguing about pizza sleeps exactly
// like any other phone — which is the difference between a tool that respects a battery
// and one a dealer stops trusting with their evening.
const WakeLock = {
// The browser's promise to stay awake. Null whenever we aren't holding one, which
// includes every moment the browser has taken it back on us — see `start()`.
sentinel: null,
// What the last sync decided we want. Kept separate from `sentinel` because
// requesting one is asynchronous: a hand can be dealt and folded out inside the
// await, and the answer to "should we still be holding this" has to be the current
// one rather than the one that was true when the request went out.
wanted: false,
// One request at a time. `sync()` runs off renderAll, which fires many times a
// second during a busy street; without this, two overlapping requests both get
// past the "already holding one?" check and the second sentinel leaks — the screen
// then stays awake after the hand ends, with nothing left holding a reference to
// turn it off.
pending: false,
// Whether the dealer has been told this is happening. Once per page load, not once
// per hand: the same toast at the top of all forty hands of a session would be the
// single most annoying thing the tool does.
told: false,
// Set by main.js so the toast's "Turn off" can actually turn it off.
hooks: null,
supported() {
return typeof navigator !== "undefined" && "wakeLock" in navigator;
},
// The whole decision, with no browser in it, so it can be tested as what it is:
// a rule about when a poker table needs the screen.
shouldHold(state) {
if (!state) return false;
if (state.keepScreenAwake === false) return false;
return state.bettingOpen === true;
},
// Called after anything that could start or end a hand. Safe to call constantly.
sync(state) {
this.wanted = this.supported() && this.shouldHold(state);
if (this.wanted) this.acquire();
else this.release();
},
async acquire() {
if (this.sentinel || this.pending) return;
this.pending = true;
try {
const sentinel = await navigator.wakeLock.request("screen");
// The hand can have ended while that was in flight. Letting this one go
// immediately is the only correct answer; holding it "just until the next
// sync" is how a phone stays lit on an empty table.
if (!this.wanted) {
try { await sentinel.release(); } catch { /* already gone */ }
return;
}
this.sentinel = sentinel;
// The browser releases this itself when the tab is hidden, and tells us so.
// Without this listener our `sentinel` would be a stale object and the
// re-acquire in `start()` would decline to do anything.
sentinel.addEventListener("release", () => {
if (this.sentinel === sentinel) this.sentinel = null;
});
this.tell();
} catch {
// Battery saver, a hidden tab, a browser that simply says no. All of these
// are the browser doing its job, and none of them is worth an error at a
// poker table — the tool works exactly as it did before, the screen just
// dims the way the phone's own settings say it should.
} finally {
this.pending = false;
}
},
async release() {
const sentinel = this.sentinel;
this.sentinel = null;
if (!sentinel) return;
try { await sentinel.release(); } catch { /* already released */ }
},
// Said once, at the moment it first becomes true, with the way out attached.
//
// The alternative was a permanent indicator in the header, and the header is the
// one part of this page that has been fought over for crowding more than any other.
// A dealer needs to know their phone is behaving unusually exactly once; after that
// it is a setting, and settings live in the sidebar.
tell() {
if (this.told) return;
this.told = true;
if (typeof Toast === "undefined") return;
Toast.show("Keeping the screen on while a hand is live.", {
actionLabel: "Turn off",
onAction: () => { if (this.hooks && this.hooks.turnOff) this.hooks.turnOff(); },
});
},
// A screen wake lock is not something you take once. The browser drops it whenever
// the document stops being visible — switching apps to check a rule, the phone
// locking on its own before the first hand was dealt, a notification pulled down —
// and it never comes back on its own. A tool that requests it at the start of the
// hand and assumes it still has it is a tool that works until the first interruption.
start(getState) {
if (!this.supported()) return;
document.addEventListener("visibilitychange", () => {
if (document.visibilityState !== "visible") return;
this.sync(getState());
});
},
};
if (typeof module !== "undefined" && module.exports) module.exports = WakeLock;
@@ -0,0 +1,86 @@
document.addEventListener('DOMContentLoaded', () => {
addHeadingAnchors();
trackReadingProgress();
highlightCurrentSection();
setUpSidebarFilter();
setUpMobileMenu();
});
// Hover "#" links on headings so any section can be linked to directly.
function addHeadingAnchors() {
document.querySelectorAll('.soc-content h2[id], .soc-content h3[id]').forEach(heading => {
const anchor = document.createElement('a');
anchor.className = 'soc-anchor';
anchor.href = '#' + heading.id;
anchor.textContent = '#';
anchor.setAttribute('aria-label', 'Link to this section');
heading.prepend(anchor);
});
}
function trackReadingProgress() {
const bar = document.getElementById('socProgress');
if (!bar) return;
const update = () => {
const scrollable = document.documentElement.scrollHeight - window.innerHeight;
bar.style.width = scrollable > 0 ? (window.scrollY / scrollable) * 100 + '%' : '0';
};
window.addEventListener('scroll', update, { passive: true });
update();
}
// Highlights the "On this page" entry for the section currently being read.
function highlightCurrentSection() {
const links = [...document.querySelectorAll('.soc-toc a[data-toc-id]')];
if (links.length === 0) return;
const headings = links
.map(link => document.getElementById(link.dataset.tocId))
.filter(Boolean);
const update = () => {
let current = headings[0];
for (const heading of headings) {
if (heading.getBoundingClientRect().top <= 120) current = heading;
}
links.forEach(link => link.classList.toggle('active', link.dataset.tocId === current?.id));
};
window.addEventListener('scroll', update, { passive: true });
update();
}
function setUpSidebarFilter() {
const input = document.getElementById('socFilter');
if (!input) return;
input.addEventListener('input', () => {
const query = input.value.trim().toLowerCase();
document.querySelectorAll('.soc-nav-link').forEach(link => {
link.classList.toggle('hidden', query !== '' && !link.dataset.filterText.includes(query));
});
document.querySelectorAll('.soc-nav-group').forEach(group => {
const anyVisible = group.querySelector('.soc-nav-link:not(.hidden)');
group.style.display = anyVisible ? '' : 'none';
});
});
}
function setUpMobileMenu() {
const toggle = document.getElementById('socMenuToggle');
const sidebar = document.getElementById('socSidebar');
if (!toggle || !sidebar) return;
const setOpen = open => {
sidebar.classList.toggle('open', open);
toggle.setAttribute('aria-expanded', String(open));
toggle.textContent = open ? 'Close' : 'Contents';
};
toggle.addEventListener('click', () => setOpen(!sidebar.classList.contains('open')));
sidebar.addEventListener('click', event => {
if (event.target.closest('a')) setOpen(false);
});
}
+978 -6
View File
File diff suppressed because it is too large Load Diff
+128
View File
@@ -0,0 +1,128 @@
// Saying who the blind is, before saying "cards".
//
// A dealer sets the table up out loud — button here, she's the small blind, he's the
// big blind — and only then deals. The seat menu offers "Make small blind" for exactly
// that, and it was answering by dealing the hand: betting opened, two stacks docked,
// the action bar up and waiting on the seat after the big blind. The dealer had said
// where the blinds go, and the tool heard "deal".
//
// The condition it was asking is `onlyBlindsIn`, which means "nothing has happened in
// this hand except the blinds" — true of a hand one second old, and equally true of a
// table in setup with no cards out and nothing committed. Those two need telling apart,
// which is what `blindsArePosted` is for.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
describe("before the deal", () => {
test("the blinds are not down, however much the table looks ready", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
assert.equal(state.bettingOpen, false);
// The old condition, on its own, says yes here — this is the whole bug.
assert.equal(Betting.onlyBlindsIn(state), true);
assert.equal(Betting.blindsArePosted(state), false);
});
test("a table straight out of setup, with nobody seated, is the same answer", () => {
assert.equal(Betting.blindsArePosted(HoldemState.createDefault()), false);
});
test("re-posting would have dealt the hand, which is why it is guarded", () => {
// Not a rule, a demonstration: repostBlinds ends in beginHand. Calling it in
// setup is the deal, and nothing about naming a blind should reach it.
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
Betting.repostBlinds(state);
assert.equal(state.bettingOpen, true, "chips off two stacks and the action bar up");
assert.notEqual(state.actingSeatIndex, null);
});
test("naming a blind moves a badge and no chips", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
const before = chipsOnTable(state);
// What the seat menu does: pin the override, repost only if the blinds are down.
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
if (Betting.blindsArePosted(state)) Betting.repostBlinds(state);
assert.equal(state.bettingOpen, false, "still setup — the dealer hasn't dealt");
assert.equal(state.actingSeatIndex, null, "nobody is being asked to act");
assert.deepEqual(state.seats.map(p => Betting.committed(p)), [0, 0, 0, 0, 0, 0]);
assert.equal(chipsOnTable(state), before);
// And the badges say what the dealer just said.
const blinds = HoldemState.getBlindSeats(state);
assert.equal(blinds.sbIndex, 3);
assert.equal(blinds.bbIndex, 0);
});
test("and the hand that follows posts from where they were aimed", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
if (Betting.blindsArePosted(state)) Betting.repostBlinds(state);
Betting.beginHand(state);
assert.equal(Betting.committed(state.seats[3]), 1);
assert.equal(Betting.committed(state.seats[0]), 2);
});
test("giving the button away is a marker moving too", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
const before = chipsOnTable(state);
const willRepost = Betting.blindsArePosted(state);
state.dealerSeatIndex = 2;
if (willRepost) Betting.repostBlinds(state);
assert.equal(willRepost, false);
assert.equal(state.bettingOpen, false);
assert.equal(chipsOnTable(state), before);
});
});
describe("once the hand is dealt", () => {
test("the blinds are down, and re-aiming them moves real chips", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
const before = chipsOnTable(state);
Betting.beginHand(state);
assert.equal(Betting.blindsArePosted(state), true);
assert.equal(Betting.committed(state.seats[1]), 1);
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
if (Betting.blindsArePosted(state)) Betting.repostBlinds(state);
assert.equal(Betting.committed(state.seats[1]), 0, "the old small blind got their chip back");
assert.equal(Betting.committed(state.seats[3]), 1);
assert.equal(Betting.committed(state.seats[0]), 2);
assert.equal(chipsOnTable(state), before, "no chips created or lost in the shuffle");
});
test("but not once somebody has answered them", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.call(state, state.actingSeatIndex);
assert.equal(Betting.blindsArePosted(state), false, "there is a call to unpick now");
});
test("nor on a later street", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
state.street = "flop";
assert.equal(Betting.blindsArePosted(state), false);
});
test("and a misdealt hand goes back to being a table in setup", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.takeHandBack(state);
assert.equal(Betting.blindsArePosted(state), false, "nothing is posted until the dealer deals again");
});
test("as does a hand that has been paid out", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.sweepToHandTotals(state);
HoldemState.startNextHand(state);
assert.equal(Betting.blindsArePosted(state), false);
});
});
+75
View File
@@ -4,9 +4,23 @@
// bet at, so the test that matters most is the last one: the seats it marks are
// exactly the seats the betting round refuses to ask for another chip.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
const CSS = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
// The declarations inside one rule, by its exact selector.
const ruleBody = selector => {
const at = CSS.indexOf(`\n${selector} {`);
if (at < 0) return null;
const open = CSS.indexOf("{", at);
return CSS.slice(open + 1, CSS.indexOf("}", open));
};
test("a player who moved all-in is marked", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
state.seats[3].stack = 12;
@@ -96,3 +110,64 @@ test("marking nobody doesn't move a chip", () => {
state.seats.forEach(player => Betting.isAllIn(state, player));
assert.equal(chipsOnTable(state), before);
});
// ---- And the shape of the mark, which is the felt's problem ------------------
//
// "ALL IN" is the only thing that ever lands on the stack line with a space in it, and
// a space is somewhere to break. Measured at 360x640 with twelve players all-in, it
// wanted 36px and had 34: it broke onto a second line, and an 11px line became 22.
//
// That is not a typography complaint. The puck went from 55px tall to 66, and puck
// height is what the seat ring is short of — at the ends of an ellipse the seats are
// separated vertically, so 11px taller is 11px further into the seat next door. The
// layout search then tried every width it has, found none that cleared, and fell back
// to the narrowest, which is also the one that wraps hardest.
//
// So the rule every design system gives a badge applies here too: one line, always.
describe("the mark is one line, because the ring cannot afford two", () => {
test("a stack line can never wrap", () => {
const body = ruleBody(".seatPuckStack");
assert.ok(body, "the .seatPuckStack rule should exist");
assert.match(body, /white-space:\s*nowrap/,
"the stack line must not be allowed to wrap — a second line is 11px the ring hasn't got");
});
test("and on the tightest pucks it gives up its letter-spacing to fit", () => {
// Not wrapping alone only converts a wrap into a spill: a line that cannot wrap
// and still doesn't fit paints over the seat beside it instead. The spacing is
// 3.6px of the 36 it wanted, and it is the one of the mark's three signals that
// can go — the warm colour and the bold weight stay.
const base = ruleBody(".seatPuckStack.seatPuckAllIn");
const tight = ruleBody(".seatsTight .seatPuckStack.seatPuckAllIn");
assert.ok(base, "the base all-in rule should exist");
assert.ok(tight, "the tight-tier all-in rule should exist");
const spacingOf = body => {
const match = body.match(/letter-spacing:\s*([\d.]+)em/);
return match ? Number(match[1]) : null;
};
const wide = spacingOf(base);
const narrow = spacingOf(tight);
assert.ok(wide !== null && narrow !== null, "both rules should set letter-spacing in em");
assert.ok(narrow < wide,
`the tight tier should space the mark more tightly than the ordinary one (${narrow} vs ${wide})`);
// The arithmetic that has to hold for it to fit at all: six characters at 10px
// with 4px of padding either side and a 1px border, inside a 44px puck.
const CONTENT_PX = 44 - 2 * 4 - 2 * 1;
const inkAt = spacing => 32.4 + spacing * 10 * "ALL IN".length;
assert.ok(inkAt(narrow) <= CONTENT_PX,
`"ALL IN" must fit the narrowest puck: ${inkAt(narrow).toFixed(1)}px into ${CONTENT_PX}px`);
assert.ok(inkAt(wide) > CONTENT_PX,
"the ordinary spacing is what didn't fit — if it does now, this test is measuring the wrong thing");
});
test("the two signals that survive are the ones doing the work", () => {
// The spacing exists so "ALL IN" doesn't read as money at a glance. Dropping it
// on the narrow pucks is only safe while the colour and the weight remain, so
// they are pinned here rather than left to be tidied away later.
const base = ruleBody(".seatPuckStack.seatPuckAllIn");
assert.match(base, /color:\s*#ffcf6b/i, "the warm colour separates it from a grey stack");
assert.match(base, /font-weight:\s*700/, "and the weight does the rest");
});
});
+135
View File
@@ -0,0 +1,135 @@
// What the tool says out loud between taps.
//
// The action bar carried `aria-live="polite"` on the element whose entire contents
// render() rebuilds every action, so a screen reader read the whole bar after every
// single tap. Measured, after one:
//
// "PRE-FLOP Pot $15 9 players Bob · $250 behind 0:00 ↶ dealing the hand U
// Fold Call $10 Raise"
//
// Nothing in that is wrong. It is just unusable at the speed a hand is dealt, and it
// is the documented anti-pattern: a live region has to be a stable element whose text
// changes, not a container that gets replaced.
//
// What a dealer running this by ear needs is who is on the clock and what it costs
// them. These are the sentences.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { ActionBar, Betting, HoldemState, makeTable } = require("./load");
const barOn = (state) => {
ActionBar.state = state;
ActionBar.lastAnnouncement = undefined;
return ActionBar;
};
describe("before the cards are out", () => {
test("a table that could be dealt says so", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
assert.equal(barOn(state).announcement(), "Ready to deal.");
});
test("and an empty table says what it is, rather than nothing", () => {
// This used to announce "" — silence being the honest answer when the bar was
// hidden on a table nobody was sitting at. The bar isn't hidden any more: an
// empty table is the one state a dealer meets before they know how any of this
// works, so it says so and offers the next step.
const state = HoldemState.createDefault();
assert.equal(barOn(state).announcement(), "Nobody's at the table yet.");
});
test("and a table with one named player has moved on from that", () => {
const state = HoldemState.createDefault();
state.seats[0].name = "Ann";
assert.notEqual(barOn(state).announcement(), "Nobody's at the table yet.");
});
});
describe("while a hand is running", () => {
const dealt = () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
state.seats.forEach((p, i) => { p.name = ["Ann", "Ben", "Cal", "Dee"][i] || ""; });
Betting.beginHand(state);
return state;
};
test("names who is on the clock, what it costs, and the pot", () => {
const state = dealt();
const said = barOn(state).announcement();
assert.match(said, /to act/);
assert.match(said, /\$2 to call/);
assert.match(said, /Pot \$3\./);
});
test("it is the seat that is actually acting, not the one after it", () => {
const state = dealt();
const name = HoldemState.displayName(state.seats[state.actingSeatIndex], state.actingSeatIndex);
assert.ok(barOn(state).announcement().startsWith(name + " to act"));
});
test("says checked to them rather than $0 to call", () => {
// A number nobody owes is a number a dealer has to stop and parse.
const state = dealt();
state.currentBet = 0;
assert.match(barOn(state).announcement(), /checked to them/);
assert.doesNotMatch(barOn(state).announcement(), /\$0 to call/);
});
test("a street with nobody left to act says the street is done", () => {
const state = dealt();
state.actingSeatIndex = null;
state.street = "flop";
assert.equal(barOn(state).announcement(), "Flop betting is done.");
});
test("and a finished hand points at the pot", () => {
const state = dealt();
state.seats.forEach((p, i) => { if (i !== 0) p.folded = true; });
assert.equal(Betting.isHandOver(state), true);
assert.equal(barOn(state).announcement(), "Hand over. Resolve the pot.");
});
});
describe("how often it speaks", () => {
// A live region that is handed the same sentence twice reads it out twice, and
// render() runs for a great many reasons that have nothing to do with whose turn
// it is — a resize, a panel opening, a note being typed.
const stubTarget = () => {
const el = { textContent: "" };
global.document = { getElementById: id => (id === "actionAnnouncer" ? el : null) };
return el;
};
test("says nothing new when nothing changed", () => {
const el = stubTarget();
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
Betting.beginHand(state);
const bar = barOn(state);
bar.announce();
const first = el.textContent;
assert.ok(first.length > 0);
el.textContent = "CLEARED"; // if announce() writes again, this comes back
bar.announce();
assert.equal(el.textContent, "CLEARED", "the same sentence was not written twice");
delete global.document;
});
test("but does when the action moves", () => {
const el = stubTarget();
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
Betting.beginHand(state);
const bar = barOn(state);
bar.announce();
const first = el.textContent;
Betting.call(state, state.actingSeatIndex);
bar.announce();
assert.notEqual(el.textContent, first);
assert.match(el.textContent, /to act/);
delete global.document;
});
});
+98
View File
@@ -0,0 +1,98 @@
// Perceptual contrast (APCA), and how much of it each piece of text needs.
//
// The audit already had a contrast check and it passed, which is why this exists. That
// check measures WCAG 2 contrast ratio, and WCAG 2's own authors say it is the wrong
// instrument for this page: the ratio is a luminance gap, and a luminance gap
// systematically *overstates* readability once one of the two colours is near black.
// This table's page is #141414 and its panels are darker still, so every reading it
// takes is on the side of the algorithm where it is least reliable. Text can clear
// 4.5:1 and still read as a smudge.
//
// APCA asks the other question — can text of *this size and weight* be read on this
// background — and answers it on a scale where a given number means the same
// readability at either polarity. It replaces nothing: the WCAG check stays, because
// that is the number an accessibility audit will ask for. This is the one that says
// whether a dealer can read it in a dim room.
//
// Constants are 0.98G-4g, and the implementation is checked against the eight
// published reference pairs in apcaMath.test.js. An unverified contrast checker is
// worse than none, because it launders a guess into a number.
const MAIN_TRC = 2.4;
const R_CO = 0.2126729, G_CO = 0.7151522, B_CO = 0.0721750;
const NORM_BG = 0.56, NORM_TXT = 0.57, REV_TXT = 0.62, REV_BG = 0.65;
const BLK_THRS = 0.022, BLK_CLMP = 1.414;
const SCALE_BOW = 1.14, SCALE_WOB = 1.14;
const LO_BOW_OFFSET = 0.027, LO_WOB_OFFSET = 0.027;
const DELTA_Y_MIN = 0.0005, LO_CLIP = 0.001;
// Screen luminance. Note this is a straight exponent, not the sRGB piecewise curve —
// APCA specifies the simple 2.4 power and the two are not interchangeable here.
function sRGBtoY(rgb) {
const s = c => Math.pow(c / 255.0, MAIN_TRC);
return R_CO * s(rgb[0]) + G_CO * s(rgb[1]) + B_CO * s(rgb[2]);
}
// Signed: positive is dark text on light, negative is light text on dark. The sign
// carries the polarity, and polarity is not cosmetic — the same luminance pair reads
// differently each way round, which is the whole reason this exists.
function contrast(txtY, bgY) {
txtY = txtY > BLK_THRS ? txtY : txtY + Math.pow(BLK_THRS - txtY, BLK_CLMP);
bgY = bgY > BLK_THRS ? bgY : bgY + Math.pow(BLK_THRS - bgY, BLK_CLMP);
if (Math.abs(bgY - txtY) < DELTA_Y_MIN) return 0.0;
let sapc, out;
if (bgY > txtY) {
sapc = (Math.pow(bgY, NORM_BG) - Math.pow(txtY, NORM_TXT)) * SCALE_BOW;
out = sapc < LO_CLIP ? 0.0 : sapc - LO_BOW_OFFSET;
} else {
sapc = (Math.pow(bgY, REV_BG) - Math.pow(txtY, REV_TXT)) * SCALE_WOB;
out = sapc > -LO_CLIP ? 0.0 : sapc + LO_WOB_OFFSET;
}
return out * 100;
}
function lc(textRgb, bgRgb) {
return contrast(sRGBtoY(textRgb), sRGBtoY(bgRgb));
}
// How much contrast this text needs, from the published APCA levels:
//
// Lc 90 fluent body text; and the floor for anything at 14px/400 or 12px/400
// Lc 75 the minimum for a column of body text (18px/400, 16px/500, 14px/700)
// Lc 60 the minimum for content text that isn't a column or block
// Lc 45 larger, heavier text — headlines at 36px/400 or 24px/700
// Lc 30 the absolute floor, and only for genuinely spot-read text
//
// Interpolating between those anchors rather than inventing a curve. Weight is folded
// in as an effective size because that is how the published table behaves: bolder
// strokes buy roughly the same readability as bigger ones, and 14px/700 sitting at the
// same Lc 75 as 18px/400 is the anchor that fixes the exchange rate.
function requiredLc(px, weight = 400) {
const heavier = Math.max(0, (Number(weight) || 400) - 400) / 300; // 400..700 -> 0..1
const effective = px * (1 + 0.28 * heavier); // 14px/700 ~= 18px/400
const anchors = [
[12, 90],
[14, 90],
[15, 82],
[18, 75],
[24, 60],
[36, 45],
];
if (effective <= anchors[0][0]) return anchors[0][1];
const last = anchors[anchors.length - 1];
if (effective >= last[0]) return last[1];
for (let i = 1; i < anchors.length; i++) {
const [x0, y0] = anchors[i - 1];
const [x1, y1] = anchors[i];
if (effective <= x1) {
const t = (effective - x0) / (x1 - x0);
return y0 + t * (y1 - y0);
}
}
return last[1];
}
module.exports = { sRGBtoY, contrast, lc, requiredLc };
+141
View File
@@ -0,0 +1,141 @@
// The contrast arithmetic, checked against the numbers its authors publish.
//
// A contrast checker that is wrong is worse than not having one, because it launders a
// guess into a number and then a stylesheet gets tuned to it. The eight pairs below are
// the reference values published with APCA 0.98G-4g; if the implementation drifts from
// them by so much as a rounding change, these fail and nothing downstream is believed.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { lc, contrast, sRGBtoY, requiredLc } = require("./apca");
const hex = h => {
h = h.replace("#", "");
if (h.length === 3) h = h.split("").map(c => c + c).join("");
return [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16));
};
describe("the published reference pairs", () => {
// Text first, background second, exactly as the reference table lists them.
const CASES = [
["#888", "#fff", 63.056469930209424],
["#fff", "#888", -68.54146436644962],
["#000", "#aaa", 58.146262578561334],
["#aaa", "#000", -56.24113336839742],
["#123", "#def", 91.66830811481631],
["#def", "#123", -93.06770049484275],
["#123", "#444", 8.32326136957393],
["#444", "#123", -7.526878460278154],
];
CASES.forEach(([text, bg, expected]) => {
test(`${text} on ${bg} is Lc ${expected.toFixed(2)}`, () => {
assert.ok(Math.abs(lc(hex(text), hex(bg)) - expected) < 1e-9,
`got ${lc(hex(text), hex(bg))}, want ${expected}`);
});
});
});
describe("polarity", () => {
test("is signed, and the sign is not decoration", () => {
// Dark text on light is positive, light text on dark negative. The two are not
// mirror images — swapping them changes the magnitude as well as the sign,
// which is the entire reason this is used instead of a ratio.
const dark = lc(hex("#888"), hex("#fff"));
const light = lc(hex("#fff"), hex("#888"));
assert.ok(dark > 0 && light < 0);
assert.ok(Math.abs(Math.abs(dark) - Math.abs(light)) > 5,
"the same pair reads differently each way round");
});
test("a colour on itself is nothing at all", () => {
assert.equal(lc(hex("#2c2c2c"), hex("#2c2c2c")), 0);
});
test("and so is a difference too small to see", () => {
// Below deltaYmin the answer is zero rather than a tiny number, so a rounding
// artefact can never be reported as contrast.
assert.equal(lc([44, 44, 44], [44, 44, 45]), 0);
});
});
describe("luminance", () => {
test("is the straight 2.4 power, not the sRGB piecewise curve", () => {
// Worth pinning: the two are close enough to look right and different enough to
// move a borderline reading. Mid grey under the piecewise curve is ~0.2159.
assert.ok(Math.abs(sRGBtoY([128, 128, 128]) - Math.pow(128 / 255, 2.4)) < 1e-6);
});
test("white is as close to 1 as the published coefficients get", () => {
// 1.0000001, not 1. The three coefficients are given to seven places and sum to
// a hair over one, and that is the specification rather than a mistake to
// correct — the reference pairs above only come out exact with these values.
// Asserted loosely on purpose: tightening this to 1e-12 fails on correct code.
assert.ok(Math.abs(sRGBtoY([255, 255, 255]) - 1) < 1e-6);
assert.equal(sRGBtoY([0, 0, 0]), 0);
});
test("green carries most of it", () => {
// Not arbitrary: it's why gold on green was the worst pairing on the felt.
assert.ok(sRGBtoY([0, 255, 0]) > sRGBtoY([255, 0, 0]) + sRGBtoY([0, 0, 255]));
});
test("contrast() takes luminances, lc() takes colours", () => {
assert.equal(contrast(sRGBtoY(hex("#888")), sRGBtoY(hex("#fff"))), lc(hex("#888"), hex("#fff")));
});
});
describe("how much contrast a given piece of text needs", () => {
test("the published anchors land where they should", () => {
assert.equal(Math.round(requiredLc(12, 400)), 90);
assert.equal(Math.round(requiredLc(14, 400)), 90);
assert.equal(Math.round(requiredLc(18, 400)), 75);
assert.equal(Math.round(requiredLc(24, 400)), 60);
assert.equal(Math.round(requiredLc(36, 400)), 45);
});
test("smaller text asks for more, never less", () => {
for (let px = 9; px < 40; px++) {
assert.ok(requiredLc(px, 400) >= requiredLc(px + 1, 400),
`${px}px should ask for at least as much as ${px + 1}px`);
}
});
test("weight buys size, at the rate the published table sets", () => {
// 14px/700 and 18px/400 sit at the same Lc 75 in the table, and that pair is
// what fixes the exchange rate between weight and size here.
assert.ok(Math.abs(requiredLc(14, 700) - requiredLc(18, 400)) < 3);
assert.ok(requiredLc(14, 700) < requiredLc(14, 400), "bold asks for less than regular");
});
test("it never runs off either end", () => {
assert.equal(requiredLc(4, 400), 90);
assert.equal(requiredLc(200, 900), 45);
});
});
describe("what the page's own colours measure", () => {
// These are the readings the round was built on. Pinned so that a future change to
// the tokens has to look at them rather than move them by accident.
const PANEL = hex("#1a1a1a");
test("the greys these tokens replaced were below the floor", () => {
assert.ok(Math.abs(lc(hex("#9a9a9a"), PANEL)) < 50, "the old muted grey");
assert.ok(Math.abs(lc(hex("#8a8a8a"), PANEL)) < 40, "the old dim grey");
});
test("and the ones that replaced them clear it", () => {
assert.ok(Math.abs(lc(hex("#c8c8c8"), PANEL)) >= 60, "muted");
assert.ok(Math.abs(lc(hex("#b4b4b4"), PANEL)) >= 58, "dim");
});
test("the three tiers are still three tiers", () => {
// The worry with lifting muted and dim is that they collapse into --text and the
// page flattens. They don't: the spread is wider than it was.
const text = Math.abs(lc(hex("#eeeeee"), PANEL));
const muted = Math.abs(lc(hex("#c8c8c8"), PANEL));
const dim = Math.abs(lc(hex("#b4b4b4"), PANEL));
assert.ok(text > muted && muted > dim, "still ordered");
assert.ok(text - dim > 30, "and still a real spread");
});
});
+175
View File
@@ -0,0 +1,175 @@
// Chips the pot was priced with that nobody actually paid.
//
// There are two ways a contribution gets into this tool. Tapping the action moves
// chips: the bet leaves the stack, sits in front of the player, and is swept into their
// hand total at the end of the street. The other is the "without live betting" panel —
// a base pot amount, or chips counted out on a seat — where the dealer is recording a
// hand they are watching rather than driving, and nothing ever leaves a stack.
//
// The pot wizard prices both the same way and awarding it credits the winner, so in
// that second mode the table simply grew.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Money, Betting, HoldemState, chipsOnTable } = require("./load");
const basePotTable = (base = 50, stack = 200) => {
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, 4);
state.basePotAmount = base;
["Ann", "Ben", "Cal"].forEach((name, i) => {
state.seats[i].name = name;
state.seats[i].inHand = true;
state.seats[i].stack = stack;
state.seats[i].buyIns = stack;
});
return state;
};
const pricedPot = (state) => state.seats.reduce(
(sum, p) => sum + HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount), 0);
describe("what nobody paid", () => {
test("comes off the stacks that owe it", () => {
const state = basePotTable();
const collected = Betting.collectAssumedContributions(state);
assert.equal(collected.total, 150);
assert.deepEqual(collected.taken.map(row => row.name), ["Ann", "Ben", "Cal"]);
assert.deepEqual(state.seats.slice(0, 3).map(p => p.stack), [150, 150, 150]);
});
test("and lands where the rest of the tool looks for chips in the middle", () => {
const state = basePotTable();
Betting.collectAssumedContributions(state);
assert.deepEqual(state.seats.slice(0, 3).map(p => p.amountOverride), [50, 50, 50]);
assert.equal(HoldemState.unresolvedContributions(state), 150);
});
test("chips counted out on a seat are money too", () => {
const state = basePotTable(0);
state.seats[0].chipCounts = { red: 4 }; // four $5 chips
const collected = Betting.collectAssumedContributions(state);
assert.equal(collected.total, 20);
assert.equal(state.seats[0].stack, 180);
});
});
describe("what has already been paid", () => {
test("a live bet is left exactly alone", () => {
const state = basePotTable(0);
state.smallBlind = 5;
state.bigBlind = 10;
Betting.beginHand(state);
const before = state.seats.map(p => Betting.stackOf(state, p));
const collected = Betting.collectAssumedContributions(state);
assert.equal(collected.total, 0, "the blinds are already off their stacks");
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before);
});
test("nor is a swept street taken a second time", () => {
const state = basePotTable(0);
state.smallBlind = 5;
state.bigBlind = 10;
Betting.beginHand(state);
Betting.sweepToHandTotals(state);
const before = state.seats.map(p => Betting.stackOf(state, p));
assert.equal(Betting.collectAssumedContributions(state).total, 0);
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before);
});
test("a base pot on top of a live bet only collects the difference", () => {
// The base pot says "assume everyone is in for fifty". Somebody who has already
// put twenty in owes thirty of it, not fifty.
const state = basePotTable(50);
state.seats[0].committed = 20;
state.seats[0].stack = 180;
const collected = Betting.collectAssumedContributions(state);
const ann = collected.taken.find(row => row.name === "Ann");
assert.equal(ann.amount, 30);
assert.equal(state.seats[0].stack, 150, "fifty in the middle, either way");
});
});
describe("what it will not do", () => {
test("take more than a player is holding", () => {
const state = basePotTable(50);
state.seats[1].stack = 20;
Betting.collectAssumedContributions(state);
assert.equal(state.seats[1].stack, 0, "all of it, and no further");
assert.equal(state.seats[1].amountOverride, 20);
});
test("touch an empty chair", () => {
const state = basePotTable();
const before = { ...state.seats[3] };
Betting.collectAssumedContributions(state);
assert.equal(state.seats[3].stack, before.stack);
assert.equal(state.seats[3].amountOverride, before.amountOverride);
});
test("or a seat that isn't in the hand", () => {
// The base pot stands in for "everyone put the same in", and somebody sitting
// out did not.
const state = basePotTable();
state.seats[2].inHand = false;
const collected = Betting.collectAssumedContributions(state);
assert.deepEqual(collected.taken.map(row => row.name), ["Ann", "Ben"]);
assert.equal(state.seats[2].stack, 200);
});
});
describe("the whole hand", () => {
test("conserves the chips on the table, which it did not before", () => {
const state = basePotTable();
const total = chipsOnTable(state);
const pot = pricedPot(state);
Betting.collectAssumedContributions(state);
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: pot }]);
HoldemState.startNextHand(state);
assert.equal(chipsOnTable(state), total, "six hundred before, six hundred after");
assert.deepEqual(state.seats.slice(0, 3).map(p => p.stack), [300, 150, 150]);
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
test("and the bank check stays balanced all the way through", () => {
const state = basePotTable();
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
Betting.collectAssumedContributions(state);
assert.equal(HoldemState.bankReconciliation(state).balanced, true, "chips are in the middle, not gone");
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: 150 }]);
HoldemState.startNextHand(state);
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
test("a live-betting hand is unchanged by any of this", () => {
const state = basePotTable(0);
state.smallBlind = 5;
state.bigBlind = 10;
const total = chipsOnTable(state);
Betting.beginHand(state);
[3, 0, 1, 2].forEach(() => { if (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex); });
Betting.sweepToHandTotals(state);
const pot = HoldemState.unresolvedContributions(state);
Betting.collectAssumedContributions(state);
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: pot }]);
HoldemState.startNextHand(state);
assert.equal(chipsOnTable(state), total);
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
test("collecting twice takes nothing the second time", () => {
// The pot wizard can be opened, cancelled and opened again.
const state = basePotTable();
Betting.collectAssumedContributions(state);
const stacks = state.seats.map(p => p.stack);
const again = Betting.collectAssumedContributions(state);
assert.equal(again.total, 0);
assert.deepEqual(state.seats.map(p => p.stack), stacks);
});
});
+108
View File
@@ -0,0 +1,108 @@
// The sums the contrast check runs on, tested away from a browser.
//
// The check itself needs a page to look at, but the arithmetic underneath it doesn't —
// and the arithmetic is the part that decides whether a real fault is reported or
// waved through. Faded text used to be filed as "not checked" rather than measured;
// this is the compositing that replaced that.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const LayoutAudit = require("./uiAudit");
const WHITE = { r: 255, g: 255, b: 255, a: 1 };
const BLACK = { r: 0, g: 0, b: 0, a: 1 };
const PAGE = { r: 28, g: 28, b: 28, a: 1 }; // the felt page behind everything
const PUCK = { r: 44, g: 44, b: 44, a: 1 }; // a seat's own background
describe("the ratio itself", () => {
test("black on white is the full 21:1, either way round", () => {
assert.ok(Math.abs(LayoutAudit.contrastRatio(BLACK, WHITE) - 21) < 0.01);
assert.ok(Math.abs(LayoutAudit.contrastRatio(WHITE, BLACK) - 21) < 0.01);
});
test("a colour against itself is 1:1", () => {
assert.ok(Math.abs(LayoutAudit.contrastRatio(PUCK, PUCK) - 1) < 0.001);
});
});
describe("text on a box that is being faded", () => {
// opacity on a box fades the box and its text together onto whatever is behind
// it — so a dimmed seat is not grey text on a dark puck, it is a faded puck and
// faded text both landing on the page.
const fade = alpha => ({ alpha, behind: PAGE });
test("at full strength nothing is composited", () => {
const painted = LayoutAudit.asPainted(WHITE, PUCK, { alpha: 1, behind: PAGE });
assert.deepEqual(painted.fg, WHITE);
assert.deepEqual(painted.bg, PUCK);
});
test("fading moves both the text and its background, not just the text", () => {
const painted = LayoutAudit.asPainted(WHITE, PUCK, fade(0.7));
assert.ok(painted.fg.r < WHITE.r, "the text dims");
assert.ok(painted.bg.r < PUCK.r, "and so does what it sits on");
assert.ok(painted.bg.r > PAGE.r, "but not all the way to the page");
});
test("which is what stops a fade being reported as a fault it isn't", () => {
// Dimming light text on a dark puck over a dark page costs contrast, but far
// less than measuring the faded text against the *unfaded* puck would claim.
const honest = LayoutAudit.asPainted(WHITE, PUCK, fade(0.7));
const wrong = { fg: LayoutAudit.over({ ...WHITE, a: 0.7 }, PUCK), bg: PUCK };
assert.ok(LayoutAudit.contrastRatio(honest.fg, honest.bg) >
LayoutAudit.contrastRatio(wrong.fg, wrong.bg));
});
test("a fade all the way out lands everything on the page behind", () => {
const painted = LayoutAudit.asPainted(WHITE, PUCK, fade(0));
assert.deepEqual(painted.fg, PAGE);
assert.deepEqual(painted.bg, PAGE);
});
test("with nothing known to be behind it, the fade is left alone", () => {
// Better to measure the unfaded pair than to invent a backdrop.
const painted = LayoutAudit.asPainted(WHITE, PUCK, { alpha: 0.5, behind: null });
assert.deepEqual(painted.fg, WHITE);
assert.deepEqual(painted.bg, PUCK);
});
});
describe("a fade makes small grey text worse, which is the fault it went looking for", () => {
// Deliberately not asserting a particular CSS colour here: the page's own greys are
// measured in the browser, against the real gradients, where they live. What this
// pins is the direction — that dimming a box costs its text contrast, which is why
// filing faded text as "not checked" hid faults rather than avoiding false ones.
test("the same grey scores lower once its box is dimmed", () => {
const grey = LayoutAudit.parseColour("rgb(138, 138, 138)");
const solid = LayoutAudit.asPainted(grey, PUCK, { alpha: 1, behind: PAGE });
const dimmed = LayoutAudit.asPainted(grey, PUCK, { alpha: 0.7, behind: PAGE });
assert.ok(LayoutAudit.contrastRatio(dimmed.fg, dimmed.bg) <
LayoutAudit.contrastRatio(solid.fg, solid.bg));
});
test("and a lighter grey scores higher at the same fade", () => {
const at = colour => {
const painted = LayoutAudit.asPainted(LayoutAudit.parseColour(colour), PUCK, { alpha: 0.7, behind: PAGE });
return LayoutAudit.contrastRatio(painted.fg, painted.bg);
};
assert.ok(at("rgb(174, 174, 174)") > at("rgb(138, 138, 138)"));
});
});
describe("reading a colour off the page", () => {
test("rgb and rgba both come back as numbers", () => {
assert.deepEqual(LayoutAudit.parseColour("rgb(1, 2, 3)"), { r: 1, g: 2, b: 3, a: 1 });
assert.deepEqual(LayoutAudit.parseColour("rgba(1, 2, 3, 0.5)"), { r: 1, g: 2, b: 3, a: 0.5 });
});
test("a gradient gives up every stop, because text could be over any of them", () => {
const stops = LayoutAudit.gradientStops("linear-gradient(rgb(203, 170, 240), rgb(169, 127, 214))");
assert.equal(stops.length, 2);
assert.deepEqual(stops[0], { r: 203, g: 170, b: 240, a: 1 });
assert.deepEqual(stops[1], { r: 169, g: 127, b: 214, a: 1 });
});
test("no background image, no stops", () => {
assert.deepEqual(LayoutAudit.gradientStops("none"), []);
});
});
+473
View File
@@ -0,0 +1,473 @@
// Where a player's bet sits on the felt.
//
// The chips were the last thing on the table nobody had measured. They were laid out on
// a ring of their own at a fixed 27/44 of the seat's reach — a fraction picked when the
// seat ring was itself fixed — and the choice between that ring and clinging to the
// puck was made by asking how wide the *window* was. Neither of those is a question
// about the felt. A phone held sideways is 844px across and gives the felt 117px of
// height: it passed the width test, took the ring, and every chip on it was painted
// behind a seat. The seats carry `z-index: 1` and the bets layer carries none, so a
// chip under a puck is not dimmed or half-hidden — it is gone, and with it the only
// number saying what that player has in.
//
// Everything below is geometry, so it runs without a browser. The felts are the ones
// measured on real screens: 692x117 is a phone on its side, 300x216 a phone upright.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Render } = require("./load");
const RADII = { rx: 44, ry: 42 };
const PUCK = { width: 96, height: 64 };
const CHIP = { width: 44, height: 22 };
const felt = (width, height) => ({ width, height });
const spacingOn = (box, puck = PUCK, chip = CHIP) =>
({ box, puck, chip, clearance: Render.BET_CLEARANCE_PX });
// A chip's centre in pixels on a given felt.
const chipPixels = (index, count, box, spacing, radii = RADII) => {
const pos = Render.betPosition(index, count, box.width / box.height, radii, spacing);
return { x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height };
};
const seatPixels = (index, count, box, radii = RADII) => {
const pos = Render.seatPosition(index, count, box.width / box.height, radii);
return { x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height };
};
// Two axis-aligned boxes, by their centres. The same test the seats settle among
// themselves — see seatFootprint.test.js — because it is the same question.
const boxesOverlap = (a, b, sizeA, sizeB) =>
Math.abs(a.x - b.x) < (sizeA.width + sizeB.width) / 2 - 0.5 &&
Math.abs(a.y - b.y) < (sizeA.height + sizeB.height) / 2 - 0.5;
describe("reaching the edge of a box", () => {
test("straight out the side is half the width, straight up half the height", () => {
assert.equal(Render.edgeReach(96, 64, 1, 0), 48);
assert.equal(Render.edgeReach(96, 64, 0, -1), 32);
assert.equal(Render.edgeReach(96, 64, -1, 0), 48);
});
test("a corner is whichever edge the ray meets first", () => {
// 45° out of a 96x64 box: the top and bottom are nearer than the sides, so the
// ray leaves through one of those. A single radius — half the diagonal, 57.7 —
// would put a chip 25px further out than it needs to be, which on a 117px felt
// is the difference between clear of the puck and off the table.
const diagonal = Render.edgeReach(96, 64, Math.SQRT1_2, Math.SQRT1_2);
assert.ok(Math.abs(diagonal - 32 / Math.SQRT1_2) < 1e-9, `got ${diagonal}`);
assert.ok(diagonal < 48, "and never further than the wider half-edge");
});
test("a square is the same in every direction it is asked about", () => {
const straight = Render.edgeReach(50, 50, 0, 1);
assert.equal(Render.edgeReach(50, 50, 1, 0), straight);
});
});
describe("getting one box clear of another", () => {
const PUCK96 = { width: 96, height: 88 };
const CHIP50 = { width: 50, height: 21 };
// Travel `t` along `u` from the puck's centre, then ask whether the two boxes are
// apart. This is the question clearanceReach is supposed to answer, asked the long
// way round so the answer can be checked rather than restated.
const apart = (t, ux, uy) =>
Math.abs(t * ux) >= (PUCK96.width + CHIP50.width) / 2 - 1e-9 ||
Math.abs(t * uy) >= (PUCK96.height + CHIP50.height) / 2 - 1e-9;
test("straight up and down it is what adding the two half-heights gives", () => {
assert.equal(Render.clearanceReach(PUCK96, CHIP50, 0, -1), (88 + 21) / 2);
assert.equal(Render.clearanceReach(PUCK96, CHIP50, 1, 0), (96 + 50) / 2);
});
test("on a diagonal it is more than the two reaches added together", () => {
// The bug this replaced. Two axis-aligned boxes are clear when they are apart on
// one axis, and the travel that achieves that is the reach of the box you get by
// adding their widths and their heights — which is never less, and on a diagonal
// is more, than adding the two boxes' own reaches. At 768x1024 the difference was
// 11px, and 11px is how far inside its own player's puck the chip came to rest.
const dirs = [[0.83, 0.56], [0.6, 0.8], [0.9, 0.44], [Math.SQRT1_2, Math.SQRT1_2]];
let shortfalls = 0;
dirs.forEach(([ux, uy]) => {
const summed = Render.edgeReach(PUCK96.width, PUCK96.height, ux, uy) +
Render.edgeReach(CHIP50.width, CHIP50.height, ux, uy);
const proper = Render.clearanceReach(PUCK96, CHIP50, ux, uy);
assert.ok(proper >= summed - 1e-9, `${ux},${uy}: ${proper} < ${summed}`);
if (proper <= summed + 1e-9) return;
// Where they differ, the old answer was not merely smaller — it was wrong:
// travelling that far leaves the two boxes still on top of each other.
shortfalls++;
assert.ok(!apart(summed, ux, uy),
`${ux},${uy}: adding the reaches gave ${summed.toFixed(1)}, which is enough after all`);
});
assert.ok(shortfalls >= 2, "expected the two to actually differ on some of these");
});
test("and it is exactly enough — a hair less is not", () => {
const dirs = [[0, -1], [1, 0], [0.83, 0.56], [0.6, -0.8], [-0.9, 0.44]];
dirs.forEach(([ux, uy]) => {
const t = Render.clearanceReach(PUCK96, CHIP50, ux, uy);
assert.ok(apart(t, ux, uy), `${ux},${uy}: ${t}px still overlaps`);
assert.ok(!apart(t - 0.5, ux, uy), `${ux},${uy}: ${t}px is further than needed`);
});
});
});
describe("a chip on the ring", () => {
test("with nothing measured it keeps the proportions the ring always used", () => {
// The unit suite and the very first render both call it this way. 27/44 and
// 25/42 of the seat's own reach, so the chips move with the ring rather than
// with the felt.
const seat = Render.seatPosition(3, 9, 1.4, RADII);
const chip = Render.betPosition(3, 9, 1.4, RADII, null);
assert.ok(Math.abs((chip.left - 50) - (seat.left - 50) * (27 / 44)) < 1e-9);
assert.ok(Math.abs((chip.top - 50) - (seat.top - 50) * (25 / 42)) < 1e-9);
});
test("measured, it sits just clear of its own puck rather than at a fixed fraction", () => {
const box = felt(560, 400);
const spacing = spacingOn(box);
for (let i = 0; i < 9; i++) {
const seat = seatPixels(i, 9, box);
const chip = chipPixels(i, 9, box, spacing);
assert.ok(!boxesOverlap(seat, chip, PUCK, CHIP),
`seat ${i + 1}'s own chip is under its own puck`);
}
});
test("and never wanders into the pot badge in the middle", () => {
// The floor: 38% of the way out. Without it a chip walked in past a puck that
// is 96px wide on a felt whose reach is 90px would land on the pot total, which
// is the same fault at the other end of the spoke.
const box = felt(300, 216);
const spacing = spacingOn(box, { width: 200, height: 150 });
for (let i = 0; i < 6; i++) {
const seat = seatPixels(i, 6, box);
const chip = chipPixels(i, 6, box, spacing);
const seatReach = Math.hypot(seat.x - box.width / 2, seat.y - box.height / 2);
const chipReach = Math.hypot(chip.x - box.width / 2, chip.y - box.height / 2);
assert.ok(chipReach >= seatReach * 0.38 - 0.5,
`chip ${i + 1} is ${chipReach.toFixed(1)}px out of a possible ${seatReach.toFixed(1)}`);
}
});
test("a seat sitting dead centre gets its chip put somewhere, not NaN", () => {
const box = felt(400, 300);
const pos = Render.betPosition(0, 1, box.width / box.height, { rx: 0, ry: 0 }, spacingOn(box));
assert.ok(Number.isFinite(pos.left) && Number.isFinite(pos.top), JSON.stringify(pos));
});
});
describe("deciding whether a ring of chips fits at all", () => {
// The shape the layout works in: one entry per chip on the felt, carrying its own
// measured size, because a chip is as wide as the number printed on it.
const tableOf = (count, chip = CHIP) => {
Render.state = { seatCount: count, seats: Array.from({ length: count }, () => ({})) };
return Array.from({ length: count }, (_, i) =>
({ index: i, width: chip.width, height: chip.height }));
};
test("a desktop felt has room for one", () => {
const box = felt(700, 500);
const all = tableOf(9);
assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacingOn(box)), true);
});
test("a phone on its side has not, whatever the window's width says about it", () => {
// 692x117, measured on an 844px-wide phone in landscape — a viewport that sails
// past a `max-width: 700px` test. The ring is a slot: the 38% floor puts a chip
// on the seat across the table from it.
const box = felt(692, 117);
const all = tableOf(9);
assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacingOn(box)), false,
"this is the felt where every chip was painted behind a puck");
});
test("its answer is the one the geometry gives", () => {
// Not a restatement of the two cases above: whatever it returns, it has to agree
// with laying the chips out and measuring them against every puck on the felt.
const shapes = [felt(700, 500), felt(692, 117), felt(300, 216), felt(1000, 620), felt(480, 300)];
shapes.forEach(box => {
const all = tableOf(9);
const spacing = spacingOn(box);
const aspect = box.width / box.height;
const clear = all.every(({ index: i }) => {
const chip = chipPixels(i, 9, box, spacing);
return all.every(({ index: j }) => !boxesOverlap(chip, seatPixels(j, 9, box), CHIP, PUCK));
});
assert.equal(Render.chipsClearTheSeats(all, aspect, RADII, spacing), clear,
`${box.width}x${box.height}`);
});
});
test("each chip is asked about at its own size", () => {
// A chip is as wide as the number on it: at 768x1024, four players' chips were
// 40px and one holding a four-figure bet was 47. Answering the question once, at
// the widest, told the whole felt it had no room for a ring — and dropped all
// five chips onto their own players' stacks to solve a problem four of them
// did not have.
// The felt and the pucks are the iPad's own, measured: 672x672, pucks 96x88.
const box = felt(672, 672);
const narrow = { width: 40, height: 20 };
const huge = { width: 420, height: 380 };
const spacing = { box, puck: { width: 96, height: 88 }, clearance: Render.BET_CLEARANCE_PX };
const aspect = box.width / box.height;
assert.equal(Render.chipsClearTheSeats(tableOf(9, narrow), aspect, RADII, spacing), true,
"a felt this size has room for a ring of ordinary chips");
assert.equal(Render.chipsClearTheSeats(tableOf(9, huge), aspect, RADII, spacing), false,
"and none at all for a ring of chips that size");
// Now one of each. The answer has to come from the sizes in the list, not from
// one size standing in for all of them: with one huge chip the ring is off, and
// taking that chip out of the question puts it back.
const mixed = tableOf(9, narrow);
mixed[0] = { index: 0, ...huge };
assert.equal(Render.chipsClearTheSeats(mixed, aspect, RADII, spacing), false);
assert.equal(Render.chipsClearTheSeats(mixed.slice(1), aspect, RADII, spacing), true,
"the eight ordinary ones were never the problem");
});
test("and at the chips against each other", () => {
// The ring is a smaller circle than the seats' one and holds the same number of
// things, so it is the tighter fit of the two. Measured on a Pixel 7 with twelve
// players, two $10s on the ring sat on top of each other while every puck on the
// felt was clear — so a check that only looks at the pucks says yes to a layout
// that hides one player's bet behind another's.
// The Pixel 7's own numbers, measured: a 300x336 felt on a ring of 44 by 40.84,
// pucks 44x55, a pot badge 64x53, and the seven players who had put chips in.
const box = felt(300, 336);
const radii = { rx: 44, ry: 40.84 };
const puck = { width: 44, height: 55 };
const pot = { x: 150, y: 168, width: 64, height: 53 };
const spacing = { box, puck, pot, clearance: Render.BET_CLEARANCE_PX };
const aspect = box.width / box.height;
Render.state = { seatCount: 12, seats: Array.from({ length: 12 }, () => ({})) };
const bettors = [2, 3, 4, 5, 6, 7, 8];
const all = bettors.map(index => ({ index, width: index === 2 ? 44.1 : 49.7, height: 21 }));
const chips = all.map(entry => ({
...entry,
...chipPixels(entry.index, 12, box,
{ ...spacing, chip: { width: entry.width, height: entry.height } }, radii),
}));
const seats = [...Array(12).keys()].map(i => seatPixels(i, 12, box, radii));
assert.equal(chips.some(chip => seats.some(seat => boxesOverlap(chip, seat, chip, puck))), false,
"this table is chosen so the pucks are not the problem");
assert.equal(chips.some(chip => boxesOverlap(chip, pot, chip, pot)), false,
"nor the pot");
assert.ok(chips.some((chip, i) => chips.some((other, j) => i !== j &&
boxesOverlap(chip, other, chip, other))), "and so the chips are");
assert.equal(Render.chipsClearTheSeats(all, aspect, radii, spacing), false);
});
test("it looks at every puck, not just the chip's own", () => {
// The rule this replaced walked each chip in until it was clear of its *own*
// seat and stopped there, which says nothing about the eight other pucks on the
// felt. 560x190 is a felt where that rule is satisfied and the ring is still no
// good: every chip clears the puck it belongs to, and one of them is sitting on
// a stranger's.
const box = felt(560, 190);
const all = tableOf(9);
const spacing = spacingOn(box);
const chips = all.map(({ index: i }) => chipPixels(i, 9, box, spacing));
all.forEach(({ index: i }) => assert.ok(!boxesOverlap(chips[i], seatPixels(i, 9, box), CHIP, PUCK),
`chip ${i + 1} is under its own puck, so this felt does not make the point`));
assert.ok(all.some(({ index: i }) => all.some(({ index: j }) => i !== j &&
boxesOverlap(chips[i], seatPixels(j, 9, box), CHIP, PUCK))),
"expected a chip on somebody else's puck — what the old rule could not see");
assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacing), false);
});
});
describe("a chip clinging to its puck", () => {
// The fallback when there is no room for a ring: the chip hangs off the puck on the
// side facing the empty middle of the felt.
//
// The felts the app actually draws, with the puck size each one settles on — read
// off the running site at each viewport rather than guessed here, because the tight
// felts shrink the puck too, and a test that holds the desktop's 96x64 puck against
// a 528x83 felt is describing a table the app never draws.
const REAL = [
["Galaxy S8 on its side", felt(528, 83), { width: 44, height: 55 }],
["iPhone on its side", felt(692, 113), { width: 72, height: 55 }],
["Galaxy S8 upright, six-handed", felt(248, 280), { width: 78, height: 88 }],
["Galaxy S8 upright, packed", felt(248, 280), { width: 44, height: 55 }],
];
// Chips are 21x17 everywhere they are anchored — the amount is the only thing on
// one at that size.
const TIGHT = { puck: { width: 44, height: 55 }, chip: { width: 21, height: 17 } };
// What each chip is told to do, worked out for the whole table at once.
const placeAll = (count, box, puck, chip = TIGHT.chip, radii = RADII) => {
Render.state = { seatCount: count, seats: Array.from({ length: count }, () => ({})) };
const indices = [...Array(count).keys()];
const entries = indices.map(i => ({ index: i, width: chip.width, height: chip.height }));
const spacing = { box, puck, clearance: Render.BET_CLEARANCE_PX };
const offs = Render.anchoredChipTravel(entries, count, box.width / box.height, RADII, spacing);
return indices.map(i => {
const seat = Render.seatPosition(i, count, box.width / box.height, radii);
const at = { x: (seat.left / 100) * box.width, y: (seat.top / 100) * box.height };
const off = offs[i];
return { seat: at, chip: { x: at.x + off.x, y: at.y + off.y }, off, puck };
});
};
test("on its own, a chip asks to sit just clear of its puck", () => {
const box = felt(692, 113);
const puck = { width: 72, height: 55 };
const seat = Render.seatPosition(0, 9, box.width / box.height, RADII);
const off = Render.anchoredChipOffset(seat, puck, TIGHT.chip, box, RADII);
const travel = Math.hypot(off.x, off.y);
const ux = off.x / travel, uy = off.y / travel;
assert.ok(Math.abs(travel -
(Render.clearanceReach(puck, TIGHT.chip, ux, uy) + Render.BET_CLEARANCE_PX)) < 1e-6, `travelled ${travel}`);
});
test("and gets it, where nothing else is in the way", () => {
// 692x113 is the roomier of the two landscape felts. Every chip clears its own
// puck and none of them has to give ground.
placeAll(9, felt(692, 113), { width: 72, height: 55 }).forEach((row, i) => {
assert.ok(!boxesOverlap(row.seat, row.chip, row.puck, TIGHT.chip),
`seat ${i + 1}'s chip is inside its own puck, and nothing made it go there`);
});
});
test("it goes off the rim, not down the spoke", () => {
// On a felt 692x113 the two are nowhere near the same direction. A seat on the
// top rail has a spoke pointing diagonally inward — which along a slot this flat
// runs sideways into the next seat — while off the rim is very nearly straight
// down, into the empty middle. This is the seam that put two chips on top of
// each other on a Galaxy S8.
const box = felt(692, 113);
const rows = placeAll(9, box, { width: 72, height: 55 });
const top = rows.find(r => r.seat.y < box.height * 0.3 && r.seat.x > box.width * 0.7);
assert.ok(top, "expected a seat up on the right of the top rail");
assert.ok(Math.abs(top.off.y) > Math.abs(top.off.x) * 2,
`chip went ${top.off.x.toFixed(1)},${top.off.y.toFixed(1)} — that is along the rail, not off it`);
assert.ok(top.off.y > 0, "and downward, toward the middle of the felt");
});
test("no two chips on a full table land on each other", () => {
// Four sides gave three seams. There are no sides now, and this is what that is
// worth — on every felt the app actually draws, at every table size.
REAL.forEach(([name, box, puck]) => {
[6, 9, 12].forEach(count => {
const rows = placeAll(count, box, puck);
for (let a = 0; a < rows.length; a++) {
for (let b = a + 1; b < rows.length; b++) {
assert.ok(!boxesOverlap(rows[a].chip, rows[b].chip, TIGHT.chip, TIGHT.chip),
`${name}, ${count} seats: chips ${a + 1} and ${b + 1} overlap`);
}
}
});
});
});
test("a chip only gives ground when it is standing on something", () => {
// The point of pulling chips back one at a time rather than by a rule that
// applies to all of them. Stopping every chip at the felt's midline, or scaling
// them all by one factor, takes travel from the seats at the ends of a long
// rail — measured at 692x113, that parked two chips on their own player's stack
// to solve a collision that did not exist.
//
// So: every chip either got everything it asked for, or putting the last step
// back reintroduces a collision it is part of. The step goes back for all of
// them at once, because a facing pair gives ground together and the pass that
// finally separates such a pair moves both ends of it.
const step = Render.CHIP_PULLBACK_STEP_PX;
REAL.forEach(([name, box, puck]) => {
[6, 9, 12].forEach(count => {
const rows = placeAll(count, box, puck).map(row => {
const travel = Math.hypot(row.off.x, row.off.y);
const dir = travel < 1e-6 ? { x: 0, y: 0 } : { x: row.off.x / travel, y: row.off.y / travel };
const asked = travel < 1e-6 ? 0 :
Render.clearanceReach(puck, TIGHT.chip, dir.x, dir.y) +
Render.BET_CLEARANCE_PX;
const back = Math.min(asked, travel + step);
return { ...row, travel, asked,
undone: { x: row.seat.x + dir.x * back, y: row.seat.y + dir.y * back } };
});
rows.forEach((row, i) => {
if (row.travel < 1e-6 || row.travel >= row.asked - 1e-6) return;
const onSomething = rows.some((other, j) => j !== i && (
boxesOverlap(row.undone, other.undone, TIGHT.chip, TIGHT.chip) ||
boxesOverlap(row.undone, other.seat, TIGHT.chip, puck)));
assert.ok(onSomething,
`${name}, ${count} seats: chip ${i + 1} stopped ${(row.asked - row.travel).toFixed(1)}px short of nothing`);
});
});
});
});
test("nobody is pushed further out than they asked to go", () => {
// The pull-back only ever takes travel away. A chip pushed past its clearance
// would be floating out on the felt with nothing to say whose it is.
REAL.forEach(([name, box, puck]) => {
placeAll(12, box, puck).forEach((row, i) => {
const travel = Math.hypot(row.off.x, row.off.y);
if (travel < 1e-6) return;
const ux = row.off.x / travel, uy = row.off.y / travel;
const asked = Render.clearanceReach(puck, TIGHT.chip, ux, uy) +
Render.BET_CLEARANCE_PX;
assert.ok(travel <= asked + 0.5,
`${name}: chip ${i + 1} went ${travel.toFixed(1)}px when ${asked.toFixed(1)} was asked for`);
});
});
});
test("the direction turns smoothly all the way round the ring", () => {
// The property a set of discrete sides cannot have, and the reason there are
// none: neighbouring seats get neighbouring directions, with no step anywhere.
const box = felt(560, 400);
const angles = Array.from({ length: 24 }, (_, i) => {
const seat = Render.seatPosition(i, 24, box.width / box.height, RADII);
const off = Render.anchoredChipOffset(seat, PUCK, CHIP, box, RADII);
return Math.atan2(off.y, off.x);
});
angles.forEach((angle, i) => {
const next = angles[(i + 1) % angles.length];
let turn = Math.abs(next - angle);
if (turn > Math.PI) turn = 2 * Math.PI - turn;
assert.ok(turn < Math.PI / 4,
`between seats ${i + 1} and ${i + 2} the chip jumps ${(turn * 180 / Math.PI).toFixed(0)}°`);
});
});
test("it stops short of the pot total as well", () => {
// The other thing in the middle of the felt, and the one number on the table
// that belongs to everybody. A seat on the middle of a rail sends its chip
// straight at it: on a Galaxy S8 on its side, seat 1's $10 was sitting on
// "Pot $65". The badge is measured rather than assumed to be at the centre —
// it lives on the table, not on either overlay, and the two are not concentric.
const box = felt(528, 83);
const pot = { x: 264, y: 40, width: 96, height: 34 };
Render.state = { seatCount: 12, seats: Array.from({ length: 12 }, () => ({})) };
const indices = [...Array(12).keys()];
const entries = indices.map(i =>
({ index: i, width: TIGHT.chip.width, height: TIGHT.chip.height }));
const spacing = { box, puck: TIGHT.puck, clearance: Render.BET_CLEARANCE_PX, pot };
const offs = Render.anchoredChipTravel(entries, 12, box.width / box.height, RADII, spacing);
indices.forEach(i => {
const seat = Render.seatPosition(i, 12, box.width / box.height, RADII);
const chip = { x: (seat.left / 100) * box.width + offs[i].x,
y: (seat.top / 100) * box.height + offs[i].y };
assert.ok(!boxesOverlap(chip, pot, TIGHT.chip, pot),
`chip ${i + 1} is on the pot total`);
});
});
test("a seat with no rim to be off still gets an answer", () => {
const box = felt(400, 300);
const off = Render.anchoredChipOffset({ left: 50, top: 50 }, PUCK, CHIP, box, RADII);
assert.ok(Number.isFinite(off.x) && Number.isFinite(off.y), JSON.stringify(off));
assert.ok(off.y < 0, "upward, which is where a puck's own writing is not");
});
});
+118
View File
@@ -0,0 +1,118 @@
// The community cards, as a thing the table remembers.
//
// The board is the one part of a hand this tool never had a place for: hole cards go on
// a seat, chips go in the middle, and the five cards everybody plays lived only on the
// felt in front of the dealer. It is opt-in, because most nights nobody wants to enter
// cards at all — and everything that reads it has to behave exactly as it always did
// when it is off.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Betting, HoldemState, makeTable } = require("./load");
const dealt = (over = {}) => {
const state = { ...makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2 }), ...over };
Betting.beginHand(state);
return state;
};
describe("a new table", () => {
test("has five empty slots and is not tracking them", () => {
const state = HoldemState.createDefault();
assert.deepEqual(state.board, ["", "", "", "", ""]);
assert.equal(state.trackBoard, false,
"off by default: a dealer who never asked for this should not be asked for cards");
});
});
describe("how many cards are out", () => {
test("none, three, four, five", () => {
assert.equal(Betting.boardCardsFor("preflop"), 0);
assert.equal(Betting.boardCardsFor("flop"), 3);
assert.equal(Betting.boardCardsFor("turn"), 4);
assert.equal(Betting.boardCardsFor("river"), 5);
});
test("and nothing at all for a street that isn't one", () => {
assert.equal(Betting.boardCardsFor("showdown"), 0);
assert.equal(Betting.boardCardsFor(undefined), 0);
});
test("it matches the streets the betting engine actually deals", () => {
// If a street is ever added, this is the table that has to hear about it —
// a street with no entry silently draws no cards.
Betting.STREETS.forEach(street => {
assert.ok(Object.prototype.hasOwnProperty.call(Betting.BOARD_CARDS, street),
`${street} has no card count`);
});
});
});
describe("clearing it", () => {
test("the next hand takes the board with it", () => {
const state = dealt();
state.board = ["AH", "KD", "9C", "4S", "2H"];
HoldemState.startNextHand(state);
assert.deepEqual(state.board, ["", "", "", "", ""]);
});
test("so does a misdeal", () => {
const state = dealt();
state.board = ["AH", "KD", "9C", "", ""];
Betting.takeHandBack(state);
assert.deepEqual(state.board, ["", "", "", "", ""]);
});
test("and so does dealing a hand, however the table got here", () => {
// Dealing twice off the same button — after a misdeal, or a table set up and
// then dealt again — would otherwise rank the last hand's board against this
// hand's cards, and it would look exactly like a real answer.
const state = dealt();
state.board = ["AH", "KD", "9C", "4S", "2H"];
Betting.beginHand(state);
assert.deepEqual(state.board, ["", "", "", "", ""]);
});
test("but turning the tracking off does not", () => {
// A dealer who taps the toggle mid-hand can tap it back; wiping the flop on
// the way past would cost them cards they had already entered.
const state = dealt({ trackBoard: true });
state.board = ["AH", "KD", "9C", "", ""];
state.trackBoard = false;
assert.deepEqual(state.board, ["AH", "KD", "9C", "", ""]);
});
});
describe("a board off a file", () => {
const migrated = board => HoldemState.migrate({
seats: [HoldemState.makePlayer()], board,
}).board;
test("a table saved before boards existed gets an empty one, not tracked", () => {
const parsed = HoldemState.migrate({ seats: [HoldemState.makePlayer()] });
assert.deepEqual(parsed.board, ["", "", "", "", ""]);
assert.equal(parsed.trackBoard, false);
});
test("it is always five slots, however many arrived", () => {
assert.equal(migrated(["AH"]).length, 5);
assert.equal(migrated(["AH", "KD", "9C", "4S", "2H", "3D", "5S"]).length, 5);
assert.equal(migrated("not an array").length, 5);
assert.equal(migrated(null).length, 5);
});
test("anything that isn't a card becomes an empty slot", () => {
assert.deepEqual(migrated(["AH", "ZZ", 7, null, "10H"]), ["AH", "", "", "", ""]);
});
test("and a real one keeps its place, upper-cased", () => {
assert.deepEqual(migrated(["ah", " kd ", "9c", "", ""]), ["AH", "KD", "9C", "", ""]);
});
test("tracking is only on if the file said so, in so many words", () => {
const on = HoldemState.migrate({ seats: [HoldemState.makePlayer()], trackBoard: true });
const truthy = HoldemState.migrate({ seats: [HoldemState.makePlayer()], trackBoard: "yes" });
assert.equal(on.trackBoard, true);
assert.equal(truthy.trackBoard, false, "a string is a file that has been edited, not a yes");
});
});
+129
View File
@@ -0,0 +1,129 @@
// Calling the clock on a player.
//
// The bar already had a stopwatch saying how long somebody had been thinking. This is
// what a dealer does about it: a procedure with a shape — a set time, a countdown said
// out loud, and a ruling at the end that depends on what the player was facing.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { ActionBar, Betting, makeTable } = require("./load");
// The bar rebuilds the DOM when a clock starts; none of the rules below need it.
const onTable = (state) => {
ActionBar.state = state;
ActionBar.render = () => {};
ActionBar.stopClock();
return ActionBar;
};
const dealt = () => {
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2, dealerSeatIndex: 0 });
Betting.beginHand(state);
return state;
};
describe("starting one", () => {
test("it runs on the player who is actually on the clock", () => {
const state = dealt();
const bar = onTable(state);
assert.equal(bar.clockRunning(), false, "nothing runs until it is called");
bar.callClock();
assert.equal(bar.clockRunning(), true);
assert.equal(bar.clockSeatIndex, state.actingSeatIndex);
});
test("with nobody to act there is nothing to call", () => {
const state = dealt();
state.actingSeatIndex = null;
const bar = onTable(state);
bar.callClock();
assert.equal(bar.clockRunning(), false);
assert.equal(bar.clockLeft(), null);
});
test("the dealer can put it away again", () => {
const bar = onTable(dealt());
bar.callClock();
bar.stopClock();
assert.equal(bar.clockRunning(), false);
assert.equal(bar.clockLeft(), null);
});
});
describe("the count", () => {
const at = (bar, seconds) => bar.clockLeft(bar.clockStartedAt + seconds * 1000);
test("is the player's own time and then a countdown", () => {
const bar = onTable(dealt());
bar.callClock();
assert.equal(at(bar, 0), bar.CLOCK_THINK_S + bar.CLOCK_COUNTDOWN_S);
assert.equal(at(bar, 30), bar.CLOCK_COUNTDOWN_S, "thirty gone, the countdown starts");
assert.equal(at(bar, 35), 5);
});
test("stops at zero rather than going negative", () => {
const bar = onTable(dealt());
bar.callClock();
assert.equal(at(bar, 40), 0);
assert.equal(at(bar, 600), 0, "ten minutes later it is still just up");
});
});
describe("what happens at zero", () => {
test("facing a bet, the hand is dead", () => {
const state = dealt();
const bar = onTable(state);
// Whoever is up pre-flop is facing the big blind.
const ruling = bar.clockRuling();
assert.equal(ruling.verb, "fold");
assert.match(ruling.label, /hand is dead/);
assert.match(ruling.say, /facing \$2/);
});
test("facing nothing, it is checked — slowness doesn't fold a free hand", () => {
const state = dealt();
// Everyone calls round to the big blind, who has nothing to call.
[3, 4, 5, 0, 1].forEach(seat => Betting.call(state, seat));
const bar = onTable(state);
assert.equal(state.actingSeatIndex, 2, "the big blind is up");
const ruling = bar.clockRuling();
assert.equal(ruling.verb, "check");
assert.match(ruling.label, /check for/i);
});
test("the ruling names the player, because the dealer has to say it out loud", () => {
const state = dealt();
state.seats[state.actingSeatIndex].name = "Priya";
const ruling = onTable(state).clockRuling();
assert.match(ruling.label, /Priya/);
assert.match(ruling.say, /Priya/);
});
test("with nobody to act there is no ruling to make", () => {
const state = dealt();
state.actingSeatIndex = null;
assert.equal(onTable(state).clockRuling(), null);
});
});
describe("what ends it", () => {
test("the player acting", () => {
const state = dealt();
const bar = onTable(state);
bar.callClock();
const wasOn = state.actingSeatIndex;
Betting.call(state, wasOn);
assert.notEqual(state.actingSeatIndex, wasOn, "the action has moved");
assert.equal(bar.clockRunning(), false, "so the clock is no longer this player's");
});
test("and it doesn't follow the action to the next player", () => {
// A clock is called on one decision. Left running it would put a countdown on
// somebody nobody asked about.
const state = dealt();
const bar = onTable(state);
bar.callClock();
Betting.call(state, state.actingSeatIndex);
assert.equal(bar.clockLeft(), null);
});
});
+229
View File
@@ -0,0 +1,229 @@
// Somebody leaving with their chips.
//
// The most ordinary thing that happens at a home game after midnight, and the tool had
// nothing for it. Freeing the chair meant wiping the seat by hand, and then the bank
// check — cash taken against chips on the table — declared the departing player's whole
// stack missing for the rest of the night. Wiping their buy-in too silenced it, at the
// price of losing their night from the standings and the file the dealer hands over.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Money, HoldemState, Betting, makeTable } = require("./load");
const table = () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200 });
["Ann", "Ben", "Cal", "Dee"].forEach((name, i) => {
state.seats[i].name = name;
state.seats[i].buyIns = 200;
});
return state;
};
describe("counting them out", () => {
test("the preview is what the dealer's hands follow", () => {
const state = table();
state.seats[0].stack = 350;
const preview = HoldemState.cashOutPreview(state, 0);
assert.equal(preview.name, "Ann");
assert.equal(preview.chips, 350, "what they are holding");
assert.equal(preview.boughtIn, 200);
assert.equal(preview.net, 150, "up for the night");
assert.match(preview.breakdown, /\d+×\$\d+/, "and the chips that make it");
});
test("a player who is down reads as down, not as a smaller stack", () => {
const state = table();
state.seats[1].stack = 60;
assert.equal(HoldemState.cashOutPreview(state, 1).net, -140);
});
test("a seat that never set a stack is holding the table's starting stack", () => {
const state = table();
state.defaultStack = 200;
state.seats[2].stack = null;
assert.equal(HoldemState.cashOutPreview(state, 2).chips, 200);
});
});
describe("what it refuses", () => {
test("paying somebody with money still in the middle", () => {
const state = table();
Betting.beginHand(state);
const bb = state.seats.findIndex(p => (Number(p.committed) || 0) > 0);
const refusal = HoldemState.canCashOut(state, bb);
assert.equal(refusal.ok, false);
assert.match(refusal.reason, /in the pot/);
// And the refusal is the whole story: nothing moved.
assert.equal(HoldemState.cashOut(state, bb).ok, false);
assert.equal((state.cashedOut || []).length, 0);
assert.ok(state.seats[bb].name, "the seat is untouched");
});
test("an empty chair, which has nobody to pay", () => {
const state = table();
const refusal = HoldemState.canCashOut(state, 99);
assert.equal(refusal.ok, false);
});
});
describe("the chair afterwards", () => {
test("is empty and ready for the next player", () => {
const state = table();
state.seats[0].stack = 350;
HoldemState.cashOut(state, 0);
const seat = state.seats[0];
assert.equal(seat.name, "");
assert.equal(seat.stack, null);
assert.equal(seat.buyIns, 0);
assert.equal(seat.inHand, false);
});
test("belongs to somebody new, so two people's nights can't merge", () => {
const state = table();
const wasId = state.seats[0].id;
HoldemState.cashOut(state, 0);
assert.notEqual(state.seats[0].id, wasId);
});
test("and isn't owed the blind the person before them was posting", () => {
const state = table();
state.postingIn = [state.seats[0].id, state.seats[1].id];
HoldemState.cashOut(state, 0);
assert.deepEqual(state.postingIn, [state.seats[1].id]);
});
});
describe("the bank check", () => {
test("balanced before, balanced after — the chips went somewhere known", () => {
const state = table();
state.seats[0].stack = 350;
state.seats[1].stack = 50;
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
HoldemState.cashOut(state, 0);
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.paidOut, 350);
assert.equal(bank.cashIn, 800, "their buy-in is still cash the box took");
assert.equal(bank.chipsInPlay, 450);
assert.equal(bank.balanced, true);
});
test("this is the fault it fixes: wiping the seat by hand loses $350", () => {
const state = table();
state.seats[0].stack = 350;
state.seats[1].stack = 50; // so the table starts out balanced
// What a dealer had to do before: empty the chair themselves.
state.seats[0].name = "";
state.seats[0].stack = null;
state.seats[0].inHand = false;
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.balanced, false);
assert.equal(bank.difference, -350, "the tool called it missing money");
});
test("chips that really are missing still show as missing", () => {
// Counting a cash-out must not become a way of swallowing a genuine shortfall.
const state = table();
state.seats[0].stack = 350;
state.seats[1].stack = 50; // balanced to start with
HoldemState.cashOut(state, 0);
state.seats[2].stack = 150; // and now fifty chips have gone astray
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.balanced, false);
assert.equal(bank.difference, -50);
});
test("a rake and a cash-out are counted apart", () => {
const state = table();
state.seats[0].stack = 350;
state.rakeTaken = 12;
state.seats[1].stack = 38; // the rake came out of Ben's stack
HoldemState.cashOut(state, 0);
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.raked, 12);
assert.equal(bank.paidOut, 350);
assert.equal(bank.balanced, true);
});
});
describe("the record of their night", () => {
test("they stay in the standings with what they bought and what they left with", () => {
const state = table();
state.seats[0].stack = 350;
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 150 }]);
HoldemState.cashOut(state, 0);
const ann = HoldemState.sessionStandings(state).find(row => row.name === "Ann");
assert.ok(ann, "still on the list");
assert.equal(ann.boughtIn, 200);
assert.equal(ann.chipsInFront, 350, "what the box handed over");
assert.equal(ann.cashPosition, 150);
assert.equal(ann.cashedOut, true, "and marked, so nobody pays them twice");
});
test("somebody still sitting there is not marked", () => {
const state = table();
HoldemState.cashOut(state, 0);
const ben = HoldemState.sessionStandings(state).find(row => row.name === "Ben");
assert.equal(ben.cashedOut, false);
});
test("two people leaving are both kept", () => {
const state = table();
state.seats[0].stack = 350;
state.seats[1].stack = 50;
HoldemState.cashOut(state, 0);
HoldemState.cashOut(state, 1);
assert.equal(state.cashedOut.length, 2);
assert.equal(HoldemState.bankReconciliation(state).paidOut, 400);
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
test("a table saved before any of this existed has nobody cashed out", () => {
const parsed = JSON.parse(JSON.stringify(table()));
delete parsed.cashedOut;
const migrated = HoldemState.migrate(parsed);
assert.deepEqual(migrated.cashedOut, []);
assert.equal(HoldemState.bankReconciliation(migrated).paidOut, 0);
});
test("nonsense in a saved record is cleaned up rather than trusted", () => {
const parsed = JSON.parse(JSON.stringify(table()));
parsed.cashedOut = [
{ id: "a", name: "Ann", buyIns: -5, chips: "350", at: "nope" },
{ name: "no id" },
null,
];
const migrated = HoldemState.migrate(parsed);
assert.equal(migrated.cashedOut.length, 1);
assert.equal(migrated.cashedOut[0].buyIns, 0);
assert.equal(migrated.cashedOut[0].chips, 350);
assert.equal(migrated.cashedOut[0].at, 0);
});
});
describe("the table carries on", () => {
test("the next hand deals round the empty chair", () => {
const state = table();
state.seats[0].stack = 350;
HoldemState.cashOut(state, 0);
HoldemState.startNextHand(state);
Betting.beginHand(state);
assert.equal(state.seats[0].inHand, false, "nobody is in the empty chair");
assert.equal(Betting.liveSeats(state).length, 3);
assert.ok(state.actingSeatIndex !== null, "somebody is on the clock");
});
test("and the money still adds up once it has", () => {
const state = table();
state.seats[0].stack = 350;
state.seats[1].stack = 50;
HoldemState.cashOut(state, 0);
HoldemState.startNextHand(state);
Betting.beginHand(state);
const bank = HoldemState.bankReconciliation(state);
assert.equal(Money.round(bank.chipsInPlay + bank.paidOut), bank.cashIn);
assert.equal(bank.balanced, true);
});
});
+183
View File
@@ -0,0 +1,183 @@
// Taking a chip colour out of the legend.
//
// This was one click of an unlabelled 24px button, and it was not a change to a legend.
// computeContribution() prices a seat by running the legend over that seat's chip
// counts, so a colour that leaves takes every chip counted in it off the table.
// Measured in a browser on a three-handed table: deleting the $500 row took $22,500 of
// $26,874 with it, with no confirmation, nothing said afterwards, and no way back —
// a colour added again is a new colour with a new id, while the counts stay keyed to
// the one that left. The counts were left behind too, so the saved file carried money
// that nothing could price and nothing could show.
//
// What is tested here is mostly the number in the warning, because a warning that
// overstates is worse than none: it is the one thing a dealer would have to trust at
// the exact moment they are about to lose track of the box.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { HoldemState, Money } = require("./load");
const tableWith = ({ counts = [], overrides = [], basePot = null } = {}) => {
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, 9);
state.basePotAmount = basePot;
["Christopher", "Mike from work", "Alexandra"].forEach((name, i) => {
state.seats[i].name = name;
state.seats[i].inHand = true;
state.seats[i].buyIns = 500;
state.seats[i].chipCounts = {};
});
counts.forEach(([seat, chipIndex, n]) => {
state.seats[seat].chipCounts[state.chipLegend[chipIndex].id] = n;
});
overrides.forEach(([seat, amount]) => { state.seats[seat].amountOverride = amount; });
return state;
};
const onTable = state => state.seats.reduce(
(sum, p) => sum + HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount), 0);
// ---------------------------------------------------------------------------
describe("what a chip colour is holding up", () => {
test("is the money, named seat by seat", () => {
const state = tableWith({ counts: [[0, 2, 4], [1, 2, 10]] }); // the $25 chip
const usage = HoldemState.chipColorUsage(state, state.chipLegend[2].id);
assert.equal(usage.total, 350);
assert.equal(usage.chips, 14);
assert.deepEqual(usage.seats.map(s => [s.name, s.count, s.amount]), [
["Christopher", 4, 100],
["Mike from work", 10, 250],
]);
});
test("is nothing at all for a colour nobody counted", () => {
const state = tableWith({ counts: [[0, 2, 4]] });
const usage = HoldemState.chipColorUsage(state, state.chipLegend[4].id);
assert.equal(usage.total, 0);
assert.deepEqual(usage.seats, []);
assert.ok(usage.chip, "the colour itself is still found, so it can be named");
});
test("and nothing for a colour that isn't there", () => {
const state = tableWith();
assert.deepEqual(HoldemState.chipColorUsage(state, "no-such-chip"),
{ chip: null, total: 0, chips: 0, seats: [] });
});
});
describe("the number in the warning is the number the table loses", () => {
// The whole point. Multiplying the chips by their value would have been the obvious
// way to work this out and it is wrong in both directions — the two tests below are
// the two ways it is wrong.
const dropFromRemoving = (state, chipId) => {
const before = onTable(state);
HoldemState.removeChipColor(state, chipId);
return Money.round(before - onTable(state));
};
test("a seat with a typed amount ignores its chips, so its colour is worth nothing", () => {
// computeContribution() reads amountOverride first and never reaches the counts.
// Christopher is holding ten $25 chips on paper and priced at a flat $90.
const state = tableWith({ counts: [[0, 2, 10]], overrides: [[0, 90]] });
const chipId = state.chipLegend[2].id;
const usage = HoldemState.chipColorUsage(state, chipId);
assert.equal(usage.total, 0, "$250 of chips, but the seat is not priced from them");
assert.equal(dropFromRemoving(state, chipId), 0);
});
test("a seat whose chips all leave falls back to the base pot, not to zero", () => {
// The $25s are everything Christopher has counted, so taking them away drops him
// to the table's standing ante — a loss of 50, not of 250.
const state = tableWith({ counts: [[0, 2, 10]], basePot: 200 });
const chipId = state.chipLegend[2].id;
const usage = HoldemState.chipColorUsage(state, chipId);
assert.equal(usage.total, 50, "250 of chips, but only 50 of it actually leaves");
assert.equal(dropFromRemoving(state, chipId), 50);
});
test("and across a spread of tables the two always agree", () => {
function rng(seed) {
let s = seed >>> 0;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const random = rng(20260906);
let withMoney = 0;
for (let table = 0; table < 2000; table++) {
const counts = [];
for (let seat = 0; seat < 3; seat++) {
for (let chip = 0; chip < 5; chip++) {
if (random() < 0.5) counts.push([seat, chip, 1 + Math.floor(random() * 20)]);
}
}
const overrides = [];
for (let seat = 0; seat < 3; seat++) {
if (random() < 0.25) overrides.push([seat, Math.floor(random() * 400)]);
}
const basePot = random() < 0.5 ? 200 : null;
const chipIndex = Math.floor(random() * 5);
const stated = tableWith({ counts, overrides, basePot });
const chipId = stated.chipLegend[chipIndex].id;
const claimed = HoldemState.chipColorUsage(stated, chipId).total;
const actual = dropFromRemoving(tableWith({ counts, overrides, basePot }), chipId);
if (claimed !== 0) withMoney++;
assert.equal(claimed, actual,
`table ${table}: warned ${claimed}, table moved ${actual}\n` +
JSON.stringify({ counts, overrides, basePot, chipIndex }));
}
// Guards the generator: shapes that never put money on a colour would make every
// assertion above compare zero with zero.
assert.ok(withMoney > 500, `only ${withMoney} of 2000 tables had money on the colour`);
});
test("and it can be a gain, which is said as one", () => {
// Fourteen $1 chips against a $200 standing ante: once the colour goes, the seat
// stops being priced from its chips and falls through to the ante, so the table
// goes *up* by 186. Found by the randomised check above, not by hand.
const state = tableWith({ counts: [[1, 0, 14]], basePot: 200 });
const usage = HoldemState.chipColorUsage(state, state.chipLegend[0].id);
assert.equal(usage.total, -186);
assert.equal(dropFromRemoving(state, state.chipLegend[0].id), -186);
});
});
describe("removing it", () => {
test("takes the counts with it, so nothing is left priced by a colour that is gone", () => {
const state = tableWith({ counts: [[0, 2, 4], [1, 2, 10], [1, 0, 3]] });
const chipId = state.chipLegend[2].id;
HoldemState.removeChipColor(state, chipId);
assert.equal(state.chipLegend.filter(c => c.id === chipId).length, 0);
state.seats.forEach(player => assert.ok(!(chipId in (player.chipCounts || {})),
"a count against a colour no legend entry answers to is money nothing can show"));
// The other colour on that seat is untouched.
assert.equal(state.seats[1].chipCounts[state.chipLegend[0].id], 3);
});
test("reports what it cost, measured before anything moved", () => {
const state = tableWith({ counts: [[0, 2, 4]] });
const usage = HoldemState.removeChipColor(state, state.chipLegend[2].id);
assert.equal(usage.total, 100);
assert.equal(usage.seats.length, 1);
});
test("and a colour that isn't there changes nothing", () => {
const state = tableWith({ counts: [[0, 2, 4]] });
const before = onTable(state);
const usage = HoldemState.removeChipColor(state, "no-such-chip");
assert.equal(usage.chip, null);
assert.equal(state.chipLegend.length, 5);
assert.equal(onTable(state), before);
});
});
+164
View File
@@ -0,0 +1,164 @@
// The stylesheet has to keep picking from the scales.
//
// Before the token block went in, holdem.css used 42 different spacing values, 31 font
// sizes, 14 border-radii and 139 colours, and defined three custom properties. None of
// that was anybody's decision — each number was chosen at the moment it was needed, and
// the result read as crowded even where nothing overlapped, because a 0.42rem gap next
// to a 0.45rem gap next to a 0.5rem gap gives the eye no rhythm to lock onto.
//
// A one-off cleanup would last until the next `padding: 0.35rem`. So the scales are
// checked here instead: a new value has to be a step on one of them, or an exception
// with a reason written next to it.
//
// This reads the stylesheet as text rather than through a CSS parser, on purpose — the
// rest of this suite has no dependencies and runs in a second, and a regex over
// declarations is enough to catch the thing that actually happens, which is somebody
// (me) typing a number instead of reaching for a token.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const CSS_PATH = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css");
const css = fs.readFileSync(CSS_PATH, "utf8");
// Comments carry measurements and rationale — "measured at 342px tall on a phone" —
// and none of that is a declaration. Strip them before looking for values.
const declarations = css.replace(/\/\*[\s\S]*?\*\//g, "");
// Everything after the token block. The :root block is where the raw values are
// supposed to be.
const body = declarations.slice(declarations.indexOf("#potBadgeAmount,"));
const valuesIn = (properties, source = body) => {
const found = [];
// The lookbehind rejects a hyphen as well as a word character, so `scroll-padding`
// isn't read as `padding`. It caught the pot wizard's scroll-padding the moment
// that went in — correctly by its own rules, wrongly in substance: those two values
// are the measured heights of the dialog's sticky header and footer, a clearance
// like the action bar's, not a step in the spacing rhythm.
const re = new RegExp(`(?<![\\w-])(${properties.join("|")})\\s*:([^;}]*)`, "g");
let m;
while ((m = re.exec(source)) !== null) {
const [, prop, value] = m;
// calc() and env() do their own arithmetic on tokens; the tokens inside them
// are still checked, because the regex below looks at bare numbers only.
(value.match(/(?<![\w-])[0-9.]+(rem|px|em)\b/g) || []).forEach(v => found.push({ prop, value: v }));
}
return found;
};
describe("the spacing scale", () => {
// Hairlines and structural clearances, each one deliberate:
// 0px/1px/3px/4px/22px — borders, hairline offsets, and the 22px of room a seat
// puck's name gives the dealer button beside it.
// 5rem/9rem — how far the page has to scroll past the fixed action
// bar. Not rhythm; clearance for a thing of a known size.
const ALLOWED = new Set(["0px", "1px", "2px", "3px", "4px", "22px", "5rem", "9rem"]);
test("every gap, margin and padding comes off the scale", () => {
const offenders = valuesIn(
["padding", "padding-top", "padding-right", "padding-bottom", "padding-left",
"margin", "margin-top", "margin-right", "margin-bottom", "margin-left",
"gap", "row-gap", "column-gap"])
.filter(v => !ALLOWED.has(v.value));
assert.deepEqual(offenders, [],
"use var(--sp-0 … --sp-6), or add the value to ALLOWED with a reason");
});
test("and the scale is the one the tokens define", () => {
["--sp-0", "--sp-1", "--sp-2", "--sp-3", "--sp-4", "--sp-5", "--sp-6"]
.forEach(token => assert.match(css, new RegExp(`\\${token}:`), `${token} is not defined`));
});
});
describe("the type scale", () => {
// 16px — the size below which iOS zooms the page when a field is focused, so
// every text input states it. Not a typographic choice.
// 1.05em, 0.44rem — relative bumps inside a component, and the shrink-to-fit for
// a six-figure stack on a 58px seat puck.
const ALLOWED = new Set(["16px", "1.05em", "0.44rem"]);
test("every font-size comes off the scale", () => {
const offenders = valuesIn(["font-size"]).filter(v => !ALLOWED.has(v.value));
assert.deepEqual(offenders, [],
"use var(--fs-2xs … --fs-2xl), or add the value to ALLOWED with a reason");
});
test("and the scale is the one the tokens define", () => {
["--fs-2xs", "--fs-xs", "--fs-sm", "--fs-base", "--fs-md", "--fs-lg", "--fs-xl", "--fs-2xl"]
.forEach(token => assert.match(css, new RegExp(`\\${token}:`), `${token} is not defined`));
});
});
describe("the corner scale", () => {
test("every border-radius comes off it", () => {
const offenders = valuesIn(["border-radius"]).filter(v => v.value !== "2px");
assert.deepEqual(offenders, [], "use var(--r-sm | --r-md | --r-lg | --r-pill)");
});
});
describe("the tokens themselves", () => {
test("every var() the stylesheet reaches for exists", () => {
const used = new Set((css.match(/var\((--[a-zA-Z0-9-]+)/g) || []).map(s => s.slice(4)));
// Published from JS at runtime rather than declared here — see actionBar.js for
// the first two, render.js for the seat width and the chip's denomination colour,
// which is picked per chip from the amount on it, and feedback.js for the
// toast's height, which is one line or two depending on what is being said.
// The seat sheet's height is the same idea as the action bar's, and for the same
// reason: two things fixed to the bottom of a phone, and the toast has to clear
// whichever is taller. It changes when a section of the sheet is unfolded, so it
// is measured rather than written down — see Render.watchSeatSheetHeight.
const fromScript = new Set([
"--actionBarHeight", "--feltRoomPx", "--seatPuckW", "--betChipColour",
"--toastHeight", "--seatSheetHeight",
]);
// Defined by the site's own stylesheet, not this one.
const fromSite = new Set(["--font-mono"]);
const missing = [...used].filter(t =>
!fromScript.has(t) && !fromSite.has(t) && !new RegExp(`\\${t}\\s*:`).test(css));
assert.deepEqual(missing, []);
});
test("and the dark surfaces stay off pure black", () => {
// Shadows are invisible on #000, so depth has to come from a lighter surface —
// and white text on true black is the pairing that smears on an OLED phone,
// which is what this tool is used on.
const surfaces = [...css.matchAll(/--surface-\d+:\s*(#[0-9a-fA-F]{3,8})/g)].map(m => m[1].toLowerCase());
assert.ok(surfaces.length >= 4, "the surface ramp should have four steps");
surfaces.forEach(hex => {
assert.notEqual(hex, "#000");
assert.notEqual(hex, "#000000");
});
});
});
describe("numbers that change while the dealer watches", () => {
test("are set in tabular figures", () => {
// A "1" is narrower than an "8" in a proportional face, so a pot ticking from
// $18 to $21 shuffles its digits sideways and the eye reads movement where
// there is only arithmetic.
assert.match(css, /font-variant-numeric:\s*tabular-nums/);
["#potBadgeAmount", ".seatPuckStack", ".actionPot", ".standingsRow"]
.forEach(sel => assert.ok(css.includes(sel),
`${sel} should be in the tabular-figures list`));
});
});
describe("controls that must not resize under a thumb", () => {
test("the undo pill has a fixed width, not a content width", () => {
// It names the last action, so its label changes on every tap. Measured across
// one hand before this: 148, 110, 109, 108, 109, 110, 120.
const rule = css.match(/\.actionUndo\s*\{[^}]*\}/);
assert.ok(rule, ".actionUndo rule not found");
assert.match(rule[0], /width:\s*[0-9.]+rem/);
});
test("and its label is the part that ellipsizes", () => {
const rule = css.match(/\.actionUndoLabel\s*\{[^}]*\}/);
assert.ok(rule, ".actionUndoLabel rule not found");
assert.match(rule[0], /text-overflow:\s*ellipsis/);
});
});
+144
View File
@@ -0,0 +1,144 @@
// How tall the felt is allowed to be.
//
// The bar is fixed to the bottom of the screen and grows with the hand, so the table
// has to be told what is left. Everything below is the arithmetic that decides it —
// the part that can be wrong without anyone noticing until a seat is behind the Fold
// button.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { ActionBar } = require("./load");
// window height, bar height, and how far down the page the felt starts.
const room = (windowHeight, barHeight, top) => ActionBar.feltRoomFor(windowHeight, barHeight, top);
describe("the room left for the felt", () => {
test("is what the window has after the bar, the header and the bottom seat", () => {
const reserved = ActionBar.SEAT_OVERHANG_PX + ActionBar.FELT_GAP_PX;
assert.equal(room(900, 127, 146), 900 - 127 - 146 - reserved);
});
test("shrinks when the bar grows mid-hand", () => {
// Side pots make the bar taller exactly when the dealer most needs to see who
// is still in.
const quiet = room(900, 117, 146);
const sidePots = room(900, 172, 146);
assert.equal(quiet - sidePots, 55);
});
test("leaves the bottom seat's overhang out of it", () => {
// The seat pucks hang below the felt's edge; the felt has to stop short by that
// much or the ring ends up in the bar even though the felt doesn't.
assert.ok(ActionBar.SEAT_OVERHANG_PX > 30, "half a puck");
assert.ok(room(900, 127, 146) < 900 - 127 - 146);
});
});
describe("a window too short to give what was asked", () => {
// A phone on its side: 390px tall, 127px of that is the bar and 119px is
// everything above the felt. What's left is under 100.
const landscape = () => room(390, 127, 119);
test("gets the measurement, not a comfortable number", () => {
// The floor used to be 220px, which this window hasn't got. Holding out for it
// put the bottom row of seats 26px inside the action bar — the exact fault the
// measurement exists to prevent.
assert.ok(landscape() < 150, `${landscape()}px`);
assert.ok(landscape() + 119 + 127 <= 390, "the felt still ends above the bar");
});
test("still draws a table rather than a line", () => {
assert.equal(ActionBar.feltRoomFor(200, 160, 100), ActionBar.FELT_FLOOR_PX);
assert.equal(ActionBar.feltRoomFor(0, 0, 0), ActionBar.FELT_FLOOR_PX);
});
test("never returns something a browser can't lay out", () => {
[[390, 300, 200], [100, 90, 90], [-10, 0, 0]].forEach(([h, bar, top]) => {
const answer = ActionBar.feltRoomFor(h, bar, top);
assert.ok(Number.isFinite(answer) && answer > 0, `${h}/${bar}/${top} gave ${answer}`);
});
});
});
describe("a window with room to spare", () => {
test("hands over everything that's left, floor or no floor", () => {
const reserved = ActionBar.SEAT_OVERHANG_PX + ActionBar.FELT_GAP_PX;
assert.equal(room(1000, 127, 146), 1000 - 127 - 146 - reserved);
assert.equal(room(1366, 120, 206), 1366 - 120 - 206 - reserved);
});
test("gives a whole number of pixels", () => {
assert.equal(room(844.4, 127.6, 223.2) % 1, 0);
});
});
describe("when the felt changes size, so does everything on it", () => {
// The felt is published as a custom property and the seats follow it on their own,
// because they are placed in percentages of it. A bet chip anchored inside a seat
// is not: it is placed in pixels off that seat's centre, worked out against the felt
// as it was, and nothing re-ran that when the felt moved.
//
// Measured at 640x360 with five players, on a felt that lost 23px of height: the
// chips were positioned for the old one and painted on the new, and seat 3's $5 came
// to rest on seat 4's puck. The seat pucks had the same problem — they are searched
// for a width the felt no longer has — and two pairs of them were overlapping.
//
// A side pot appearing does this too: it is 55px off the felt, mid-hand.
const fakeDom = (windowHeight = 900) => {
const root = { style: { values: {},
setProperty(k, v) { this.values[k] = v; },
getPropertyValue(k) { return this.values[k] || ""; } } };
global.document = {
documentElement: root,
getElementById: id => (id === "pokerTableWrap"
? { getBoundingClientRect: () => ({ top: 146 }) }
: null),
};
global.window = { innerHeight: windowHeight, scrollY: 0 };
return root;
};
const spyRender = () => {
const calls = { density: 0, seats: 0 };
global.Render = {
state: {},
updateSeatDensity() { calls.density++; },
renderSeats() { calls.seats++; },
};
return calls;
};
test.afterEach(() => {
delete global.document;
delete global.window;
delete global.Render;
});
test("a felt that changed height lays the seats out again", () => {
fakeDom();
const calls = spyRender();
ActionBar.publishFeltRoom(117);
assert.equal(calls.seats, 1, "first publish sets a height it did not have");
ActionBar.publishFeltRoom(172);
assert.equal(calls.seats, 2, "and a side pot grew the bar by 55px");
});
test("a felt that did not change is left alone", () => {
fakeDom();
const calls = spyRender();
ActionBar.publishFeltRoom(117);
ActionBar.publishFeltRoom(117);
ActionBar.publishFeltRoom(117);
assert.equal(calls.seats, 1,
"re-laying the table on every resize tick would be work for nothing");
assert.equal(calls.density, 3, "the cheap re-measure still runs each time");
});
test("it publishes the height whether or not anything is listening", () => {
const root = fakeDom();
delete global.Render;
assert.doesNotThrow(() => ActionBar.publishFeltRoom(117));
assert.match(root.style.getPropertyValue("--feltRoomPx"), /^\d+px$/);
});
});
+165
View File
@@ -0,0 +1,165 @@
// What shape the felt ends up, which is a question about which rule wins.
//
// feltRoom.test.js covers how much room there is. This covers what the stylesheet does
// with it, and it exists because of a bug that no amount of reading caught: the upright
// oval for phones was written into the `max-width: 700px` block, and the `max-width:
// 900px` block below it set the same custom property back to 1. Both queries match a
// phone, the two declarations have identical specificity, and the later one wins. The
// rule was correct, the arithmetic was correct, and the property was 1 on every phone
// on the site. It measured as a no-op and would have shipped as one.
//
// So this walks the media queries the way the cascade does — in source order, keeping
// the last one that matches — instead of trusting that a rule written for a viewport is
// the rule that viewport gets.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const CSS = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
// Comments carry example numbers and at least one worked-through `--feltAspect: 1`, so
// they come out before anything here counts declarations.
const SOURCE = CSS.replace(/\/\*[\s\S]*?\*\//g, "");
// --- just enough of a media query engine to answer "does this viewport match?" -------
//
// Only the forms this stylesheet actually uses: width and height bounds, orientation,
// and `and` between them. Anything else is refused loudly rather than guessed at, so a
// new kind of query can't quietly start matching everything.
const matches = (condition, view) => {
if (!condition) return true;
return condition.split(/\s+and\s+/).every(part => {
const m = part.trim().match(/^\(\s*([a-z-]+)\s*:\s*([^)]+?)\s*\)$/);
assert.ok(m, `feltShape can't read the query "${part}" — teach it or simplify the CSS`);
const [, feature, raw] = m;
const px = parseFloat(raw);
switch (feature) {
case "min-width": return view.width >= px;
case "max-width": return view.width <= px;
case "min-height": return view.height >= px;
case "max-height": return view.height <= px;
case "orientation":
return raw === (view.width > view.height ? "landscape" : "portrait");
default:
assert.fail(`feltShape doesn't know the media feature "${feature}"`);
}
});
};
// Every style rule in the file, in source order, with the media condition it sits
// under. Written as a brace walk rather than a regex because a regex for `max-height`
// also finds it in `@media (orientation: landscape) and (max-height: 560px)`, and a
// media feature is not a declaration.
const rules = () => {
const out = [];
const open = [];
let prelude = "", i = 0;
while (i < SOURCE.length) {
const c = SOURCE[i];
if (c === "{") {
const head = prelude.trim();
prelude = "";
if (head.startsWith("@")) { open.push(head); i++; continue; }
let depth = 1, j = i + 1;
while (j < SOURCE.length && depth > 0) {
if (SOURCE[j] === "{") depth++;
else if (SOURCE[j] === "}") depth--;
j++;
}
const media = open.filter(h => h.startsWith("@media"))
.map(h => h.replace(/^@media\s*/, "")).join(" and ");
out.push({ selector: head, condition: media || null, body: SOURCE.slice(i + 1, j - 1) });
i = j;
continue;
}
if (c === "}") { open.pop(); prelude = ""; i++; continue; }
prelude += c;
i++;
}
return out;
};
const ALL_RULES = rules();
// The cascade, for equal-specificity declarations of one property on one selector: the
// last one whose query matches is the one the viewport gets.
const winnerAt = (selector, prop, view) => {
const winning = ALL_RULES
.filter(r => r.selector === selector && matches(r.condition, view))
.map(r => r.body.match(new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`)))
.filter(Boolean)
.pop();
assert.ok(winning, `nothing sets ${prop} on ${selector} at ${view.width}x${view.height}`);
return winning[1].trim();
};
const PHONES = [
{ width: 360, height: 640, name: "Galaxy S8" },
{ width: 375, height: 667, name: "iPhone SE" },
{ width: 390, height: 844, name: "iPhone 14" },
{ width: 412, height: 915, name: "Pixel 7" },
{ width: 430, height: 932, name: "Pro Max" },
];
// ---------------------------------------------------------------------------
describe("the shape a phone gets", () => {
test("is an oval standing up, on every one of them", () => {
PHONES.forEach(phone => {
const aspect = parseFloat(winnerAt("#pokerTableWrap", "--feltAspect", phone));
assert.ok(aspect > 1.2,
`${phone.name} lands on --feltAspect: ${aspect}. A portrait phone is ` +
`tall and this is what uses it; anything at or under 1 is the square ` +
`felt that threw the room away.`);
});
});
test("and a wide screen still gets one lying down", () => {
// The desktop felt is the shape it always was. Whatever the phones do, this is
// the one a poker table looks like when there is room for it.
assert.equal(parseFloat(winnerAt("#pokerTableWrap", "--feltAspect", { width: 1280, height: 900 })), 0.72);
});
test("with the middle widths in between left round", () => {
// 701-900px is one column and wide enough that neither oval is called for. It
// has to be written as a range: a bare max-width would match the phones too and,
// sitting lower in the file, would win there.
[{ width: 768, height: 1024 }, { width: 900, height: 700 }].forEach(view => {
assert.equal(parseFloat(winnerAt("#pokerTableWrap", "--feltAspect", view)), 1);
});
});
});
describe("what stops the oval", () => {
const phoneRule = winnerAt("#pokerTableWrap", "max-height", { width: 390, height: 844 });
test("is the room and nothing but the room", () => {
// The oval is a preference and the room is a fact. Whatever shape is asked for
// above, the felt gives way to the measurement rather than the other way round,
// because the thing on the other side of the cap is a seat behind the action bar.
assert.equal(phoneRule, "var(--feltRoomPx, 100vh)");
});
test("and it is not floored, however tempting that is", () => {
// A floor at the desktop oval — max(var(--feltRoomPx), 72cqw) — was written here
// and measured out again. Twelve players all in with nine side pots is the
// tallest the bar gets, 341px, and at 360x640 that leaves 142px of room. Floored
// to 242 the felt keeps its shape and hides five of the twelve seats behind the
// bar. Unfloored at 142 it is flat and all twelve are visible.
//
// The slit it was defending against wasn't reachable: that came from forcing
// --feltRoomPx to 10px by hand, and the real floor is FELT_FLOOR_PX at 96.
assert.doesNotMatch(phoneRule, /max\(/,
"a floor here trades a flat table for a covered seat, which is the worse of " +
"the two — see the comment above the rule");
});
test("and the width is let go of, so the height can be the one that binds", () => {
// The wide layout sizes the felt from its width and caps that. On a phone the
// width is the column and the height is what's scarce, so the cap moves.
assert.equal(winnerAt("#pokerTableWrap", "max-width", { width: 390, height: 844 }), "none");
});
});
+415
View File
@@ -0,0 +1,415 @@
// What beats what.
//
// This is the one module in the tool that can pay the wrong person without anybody
// noticing. A pot wizard that asks "who won?" is only ever as wrong as the dealer
// ticking the box; one that answers the question itself is wrong on its own account,
// silently, and a table that has come to trust it will not check. So the ordering is
// tested from both ends: every category against every other, the traps that a
// hand-rolled evaluator gets wrong (the wheel, the counterfeited kicker, the board
// that plays), and then a lot of random seven-card hands against a second
// implementation written a different way.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { HandRank } = require("./load");
const best = (...cards) => HandRank.best(cards.flat());
const beats = (a, b) => HandRank.compare(a, b) > 0;
const chops = (a, b) => HandRank.compare(a, b) === 0;
// A hand written as one string, for the tests that list a lot of them.
const hand = str => str.trim().split(/\s+/);
describe("the five-card hand", () => {
const of = str => HandRank.evaluate5(hand(str));
test("each category is recognised", () => {
const cases = [
["AS KS QS JS TS", HandRank.STRAIGHT_FLUSH, "Royal flush"],
["9H 8H 7H 6H 5H", HandRank.STRAIGHT_FLUSH, "Straight flush, nine high"],
["7C 7D 7H 7S 2C", HandRank.FOUR_OF_A_KIND, "Four of a kind, sevens"],
["KC KD KH 3S 3C", HandRank.FULL_HOUSE, "Full house, kings full of threes"],
["AD JD 8D 5D 2D", HandRank.FLUSH, "Flush, ace high"],
["9C 8D 7H 6S 5C", HandRank.STRAIGHT, "Straight, nine high"],
["QC QD QH 7S 2C", HandRank.THREE_OF_A_KIND, "Three of a kind, queens"],
["AC AD 8H 8S 4C", HandRank.TWO_PAIR, "Two pair, aces and eights"],
["QC QD 9H 5S 2C", HandRank.ONE_PAIR, "Pair of queens"],
["AC JD 8H 5S 2C", HandRank.HIGH_CARD, "Ace high"],
];
cases.forEach(([cards, category, described]) => {
const ranked = of(cards);
assert.equal(ranked.category, category, cards);
assert.equal(HandRank.describe(ranked), described, cards);
});
});
test("the categories are in the right order", () => {
// Listed weakest first, then every pair checked both ways round. The order
// itself is the thing being asserted, so it is written out rather than derived
// from the constants it is meant to be checking.
const ladder = [
"AC JD 8H 5S 2C", // ace high
"2C 2D 8H 5S 3C", // a pair of deuces
"AC AD 8H 8S 4C", // two pair
"3C 3D 3H 7S 2C", // trips
"9C 8D 7H 6S 5C", // straight
"AD JD 8D 5D 2D", // flush
"2C 2D 2H 3S 3C", // full house
"7C 7D 7H 7S 2C", // quads
"9H 8H 7H 6H 5H", // straight flush
];
for (let low = 0; low < ladder.length; low++) {
for (let high = low + 1; high < ladder.length; high++) {
assert.ok(beats(of(ladder[high]), of(ladder[low])),
`${ladder[high]} should beat ${ladder[low]}`);
assert.ok(!beats(of(ladder[low]), of(ladder[high])),
`${ladder[low]} should not beat ${ladder[high]}`);
}
}
});
test("the same hand in different suits is a chop", () => {
assert.ok(chops(of("AC KD 8H 5S 2C"), of("AS KH 8D 5C 2H")));
assert.ok(chops(of("QC QD 9H 5S 2C"), of("QH QS 9D 5C 2H")));
});
test("nothing but five real cards is a hand", () => {
assert.equal(HandRank.evaluate5(hand("AC KD 8H 5S")), null, "four");
assert.equal(HandRank.evaluate5(hand("AC KD 8H 5S 2C 3D")), null, "six");
assert.equal(HandRank.evaluate5(["AC", "KD", "8H", "5S", "ZZ"]), null, "nonsense");
assert.equal(HandRank.evaluate5(["AC", "KD", "8H", "5S", ""]), null, "an empty slot");
assert.equal(HandRank.evaluate5(null), null);
});
test("case does not matter, because the picker is not the only way cards get in", () => {
assert.ok(chops(of("ac kd 8h 5s 2c"), of("AC KD 8H 5S 2C")));
});
});
describe("the wheel", () => {
const of = str => HandRank.evaluate5(hand(str));
// The straight that runs through the ace at the bottom. An evaluator that scores a
// straight by its top card and lets the ace keep its usual value makes A-2-3-4-5
// the best straight there is instead of the worst — which pays the wrong player
// every time it comes up against a broadway.
test("A-2-3-4-5 is a straight", () => {
assert.equal(of("AC 2D 3H 4S 5C").category, HandRank.STRAIGHT);
assert.equal(HandRank.describe(of("AC 2D 3H 4S 5C")), "Straight, five high");
});
test("and it is the lowest one", () => {
assert.ok(beats(of("6C 5D 4H 3S 2C"), of("AC 2D 3H 4S 5C")),
"six high beats the wheel");
assert.ok(beats(of("AC KD QH JS TC"), of("AC 2D 3H 4S 5C")),
"broadway beats the wheel");
});
test("the same goes for a steel wheel", () => {
const steel = of("AH 2H 3H 4H 5H");
assert.equal(steel.category, HandRank.STRAIGHT_FLUSH);
assert.equal(HandRank.describe(steel), "Straight flush, five high");
assert.ok(beats(of("6H 5H 4H 3H 2H"), steel));
assert.ok(beats(of("AS KS QS JS TS"), steel));
});
test("an ace with a four-card run either side of it is not a straight", () => {
// K-A-2-3-4 does not wrap.
assert.equal(of("KC AD 2H 3S 4C").category, HandRank.HIGH_CARD);
assert.equal(of("QC KD AH 2S 3C").category, HandRank.HIGH_CARD);
});
});
describe("kickers", () => {
const of = str => HandRank.evaluate5(hand(str));
test("the fifth card settles two pair", () => {
assert.ok(beats(of("AC AD 8H 8S KC"), of("AH AS 8D 8C QC")));
assert.ok(chops(of("AC AD 8H 8S KC"), of("AH AS 8D 8C KD")));
});
test("and a pair, three kickers deep", () => {
assert.ok(beats(of("9C 9D AH 7S 2C"), of("9H 9S KD 7C 2D")), "first kicker");
assert.ok(beats(of("9C 9D AH 7S 2C"), of("9H 9S AD 6C 2D")), "second");
assert.ok(beats(of("9C 9D AH 7S 3C"), of("9H 9S AD 7C 2D")), "third");
});
test("the higher pair beats the better kicker", () => {
assert.ok(beats(of("TC TD 3H 2S 4C"), of("9C 9D AH KS QC")));
});
test("two pair is read by its higher pair first", () => {
assert.ok(beats(of("KC KD 2H 2S 3C"), of("QC QD JH JS AC")));
});
test("a full house is read by its trips first", () => {
assert.ok(beats(of("3C 3D 3H 2S 2C"), of("2D 2H 2C AS AD")));
});
test("a flush is read all the way down", () => {
assert.ok(beats(of("AD KD 8D 5D 3D"), of("AH KH 8H 5H 2H")));
assert.ok(chops(of("AD KD 8D 5D 3D"), of("AH KH 8H 5H 3H")));
});
});
describe("the best five out of seven", () => {
test("it finds the flush the two hole cards do not both belong to", () => {
const ranked = best(["AH", "2C"], ["KH", "QH", "7H", "3H", "9S"]);
assert.equal(ranked.category, HandRank.FLUSH);
assert.equal(HandRank.describe(ranked), "Flush, ace high");
});
test("it plays the board when the board is better than anything you hold", () => {
// Nobody can beat a royal on the board; both hands are the board itself.
const board = ["AS", "KS", "QS", "JS", "TS"];
const one = best(["2C", "3D"], board);
const two = best(["7H", "8H"], board);
assert.equal(HandRank.describe(one), "Royal flush");
assert.ok(chops(one, two), "the board plays, so it is a chop");
});
test("it does not use three hole cards, because there are only two", () => {
const ranked = best(["AC", "AD"], ["AH", "KS", "KD", "2C", "3H"]);
assert.equal(HandRank.describe(ranked), "Full house, aces full of kings");
});
test("a counterfeited two pair falls back on the board", () => {
// Holding 6-5 on a board of 8-8-K-K-A: the player's two pair is counterfeited —
// the best five are the board's two pair with the ace, and the sixes never play.
const ranked = best(["6C", "5D"], ["8H", "8S", "KC", "KD", "AC"]);
assert.equal(HandRank.describe(ranked), "Two pair, kings and eights");
assert.deepEqual([...ranked.cards].sort(), ["8H", "8S", "AC", "KC", "KD"].sort());
});
test("five, six and seven cards are all rankable; four is not", () => {
assert.ok(best(["AC", "KD"], ["QH", "JS", "TC"]), "five");
assert.ok(best(["AC", "KD"], ["QH", "JS", "TC", "2D"]), "six");
assert.ok(best(["AC", "KD"], ["QH", "JS", "TC", "2D", "3H"]), "seven");
assert.equal(best(["AC", "KD"], ["QH", "JS"]), null, "four");
});
test("the same card twice is not a hand", () => {
// A mis-entry, not a monster. Ranking it would mean ranking a deck that does
// not exist, and the answer would look exactly as confident as a real one.
assert.equal(best(["AC", "AC"], ["KH", "QS", "JD", "2C", "3H"]), null);
assert.equal(best(["AC", "KD"], ["KD", "QS", "JD", "2C", "3H"]), null);
});
});
describe("the showdown", () => {
const board = ["AH", "KD", "9C", "4S", "2H"];
test("it names one winner and says how it read every hand", () => {
const result = HandRank.showdown(board, [
{ id: "a", cards: ["AS", "KC"] }, // two pair, aces and kings
{ id: "b", cards: ["9H", "9S"] }, // trips
{ id: "c", cards: ["AC", "3D"] }, // a pair of aces
]);
assert.equal(result.complete, true);
assert.deepEqual(result.winners, ["b"]);
assert.deepEqual(result.players.map(p => p.place), [2, 1, 3]);
assert.deepEqual(result.players.map(p => HandRank.describe(p.hand)), [
"Two pair, aces and kings",
"Three of a kind, nines",
"Pair of aces",
]);
});
test("a chop comes back as two winners on the same place", () => {
const result = HandRank.showdown(board, [
{ id: "a", cards: ["AS", "QC"] },
{ id: "b", cards: ["AD", "QH"] },
{ id: "c", cards: ["7H", "5S"] },
]);
assert.deepEqual(result.winners.sort(), ["a", "b"]);
assert.deepEqual(result.players.map(p => p.place), [1, 1, 3],
"and the player behind them is third, not second");
});
test("an incomplete board is not a showdown", () => {
const result = HandRank.showdown(["AH", "KD", "9C"], [
{ id: "a", cards: ["AS", "KC"] },
{ id: "b", cards: ["9H", "9S"] },
]);
assert.equal(result.boardComplete, false);
assert.equal(result.complete, false);
assert.deepEqual(result.winners, [], "it must not name one");
});
test("one unknown hand and it will not say who won", () => {
// Who is *ahead* and who *won* are different claims, and the second one is the
// only one that can pay somebody. A player who never turned their cards over
// could have anything.
const result = HandRank.showdown(board, [
{ id: "a", cards: ["AS", "KC"] },
{ id: "b", cards: ["", ""] },
]);
assert.equal(result.complete, false);
assert.deepEqual(result.winners, []);
assert.deepEqual(result.unknown, ["b"]);
assert.ok(result.players[0].hand, "but it still reads the hands it can");
assert.equal(result.players[1].hand, null);
});
test("a card in two places stops it dead", () => {
const result = HandRank.showdown(board, [
{ id: "a", cards: ["AH", "KC"] }, // the ace of hearts is on the board
{ id: "b", cards: ["9H", "9S"] },
]);
assert.equal(result.duplicated, true);
assert.equal(result.complete, false);
assert.deepEqual(result.winners, []);
});
test("no players at all is not a showdown either", () => {
const result = HandRank.showdown(board, []);
assert.equal(result.complete, false);
assert.deepEqual(result.winners, []);
});
});
describe("against a second opinion", () => {
// The strongest check available without a poker library: a completely separate
// ranking, written the other way round — every hand turned into a sortable string
// by counting ranks, with no shared helper between it and the module. Where the two
// disagree, one of them is wrong and the test says which hand it was.
const VALUE = { T: 10, J: 11, Q: 12, K: 13, A: 14 };
const val = card => VALUE[card[0]] || Number(card[0]);
function reference5(cards) {
const ranks = cards.map(val).sort((x, y) => y - x);
const suited = new Set(cards.map(c => c[1])).size === 1;
const tally = {};
ranks.forEach(r => { tally[r] = (tally[r] || 0) + 1; });
const bySize = Object.keys(tally)
.map(Number)
.sort((x, y) => tally[y] - tally[x] || y - x);
const sizes = bySize.map(r => tally[r]).join("");
const unique = [...new Set(ranks)];
let run = null;
if (unique.length === 5) {
if (unique[0] - unique[4] === 4) run = unique[0];
else if (unique.join(",") === "14,5,4,3,2") run = 5;
}
// [category, ...tiebreak], the same shape, arrived at from the group sizes
// rather than from a chain of ifs about shape[0] and shape[1].
if (suited && run !== null) return [8, run];
if (sizes === "41") return [7, ...bySize];
if (sizes === "32") return [6, ...bySize];
if (suited) return [5, ...ranks];
if (run !== null) return [4, run];
if (sizes === "311") return [3, ...bySize];
if (sizes === "221") return [2, ...bySize];
if (sizes === "2111") return [1, ...bySize];
return [0, ...ranks];
}
const worse = (a, b) => {
for (let i = 0; i < Math.max(a.length, b.length); i++) {
if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) < (b[i] || 0);
}
return false;
};
function referenceBest(cards) {
let top = null;
for (let a = 0; a < 3; a++) for (let b = a + 1; b < 4; b++)
for (let c = b + 1; c < 5; c++) for (let d = c + 1; d < 6; d++)
for (let e = d + 1; e < 7; e++) {
const score = reference5([cards[a], cards[b], cards[c], cards[d], cards[e]]);
if (!top || worse(top, score)) top = score;
}
return top;
}
// A deck, and a deterministic shuffle — a failure has to be reproducible or it is
// just an anecdote about one CI run.
const DECK = [];
"23456789TJQKA".split("").forEach(r => "SHDC".split("").forEach(s => DECK.push(r + s)));
let seed = 20260904;
const random = () => {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
};
test("twenty thousand random seven-card hands rank the same way", () => {
const deck = [...DECK];
for (let round = 0; round < 20000; round++) {
// Seven off the top of a shuffle of only what it needs.
const drawn = [];
for (let i = 0; i < 7; i++) {
const pick = i + Math.floor(random() * (deck.length - i));
[deck[i], deck[pick]] = [deck[pick], deck[i]];
drawn.push(deck[i]);
}
const mine = HandRank.best(drawn);
const theirs = referenceBest(drawn);
assert.deepEqual([mine.category, ...mine.tiebreak], theirs,
`disagreed on ${drawn.join(" ")}`);
}
});
test("and four thousand random pairs of hands are ordered the same way", () => {
// The categories agreeing is not the same as the comparison agreeing: this is
// the half that pays somebody.
const deck = [...DECK];
for (let round = 0; round < 4000; round++) {
const drawn = [];
for (let i = 0; i < 9; i++) {
const pick = i + Math.floor(random() * (deck.length - i));
[deck[i], deck[pick]] = [deck[pick], deck[i]];
drawn.push(deck[i]);
}
const board = drawn.slice(0, 5);
const one = [...board, drawn[5], drawn[6]];
const two = [...board, drawn[7], drawn[8]];
const mine = Math.sign(HandRank.compare(HandRank.best(one), HandRank.best(two)));
const a = referenceBest(one), b = referenceBest(two);
const theirs = worse(a, b) ? -1 : worse(b, a) ? 1 : 0;
assert.equal(mine, theirs,
`board ${board.join(" ")}: ${drawn[5]}${drawn[6]} vs ${drawn[7]}${drawn[8]}`);
}
});
});
describe("saying it in a sentence", () => {
// The verdict line reads "Alexandra wins $25 with ___", so the phrase has to carry
// its own article. Which article is not something the first letter can answer: it
// is "a full house" and "a flush", but "four of a kind" and "two pair" take none,
// and nobody has ever said "an ace high".
test("every category takes the article a table would give it", () => {
const said = str => HandRank.phrase(HandRank.evaluate5(hand(str)));
assert.deepEqual([
said("AS KS QS JS TS"),
said("9H 8H 7H 6H 5H"),
said("7C 7D 7H 7S 2C"),
said("KC KD KH 3S 3C"),
said("AD JD 8D 5D 2D"),
said("9C 8D 7H 6S 5C"),
said("QC QD QH 7S 2C"),
said("AC AD 8H 8S 4C"),
said("QC QD 9H 5S 2C"),
said("AC JD 8H 5S 2C"),
], [
"a royal flush",
"a straight flush, nine high",
"four of a kind, sevens",
"a full house, kings full of threes",
"a flush, ace high",
"a straight, nine high",
"three of a kind, queens",
"two pair, aces and eights",
"a pair of queens",
"ace high",
]);
});
test("with nothing to say it says something", () => {
assert.equal(HandRank.phrase(null), "the best hand");
});
});
+13
View File
@@ -15,6 +15,7 @@ function load(name) {
}
const Money = load("money");
const HandRank = load("handRank");
const PotCalculator = load("potCalculator");
const SplitCalculator = load("splitCalculator");
const Betting = load("betting");
@@ -33,6 +34,15 @@ globalThis.HoldemState = HoldemState;
// its methods reach for both. Loaded last so nothing it touches is still undefined.
const ActionBar = load("actionBar");
// The picker's slot bookkeeping — which slots it is filling, what is already dealt —
// is plain state work and testable here. Only render() and open() touch the DOM.
const CardPicker = load("cardPicker");
// The wake lock's rule about when a table needs the screen is plain state work too.
// Its acquire/release bookkeeping is testable here as well, against a stand-in for
// navigator.wakeLock — see wakeLock.test.js.
const WakeLock = load("wakeLock");
// ---- Table builders ----------------------------------------------------------
// A table with `seatCount` chairs where `seated` (a list of seat indexes) are dealt
@@ -78,6 +88,7 @@ function contributions(state) {
module.exports = {
Money,
HandRank,
Render,
HandHistoryExport,
PotCalculator,
@@ -86,6 +97,8 @@ module.exports = {
Betting,
HoldemState,
ActionBar,
CardPicker,
WakeLock,
makeTable,
chipsOnTable,
contributions,
+155
View File
@@ -0,0 +1,155 @@
// Taking a hand back.
//
// The rulings sheet in this tool has told dealers to redeal since the day it was
// written — first card to the wrong seat, a seat entitled to a hand dealt out, one of
// the first two cards exposed — and the tool had no way to do it. The only control that
// ended a live hand was Next Hand, which says out loud that it takes the money off the
// table without paying anybody. That is right for an abandoned pot and wrong for a
// misdeal, where the blinds and the calls belong to the people who put them in.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Money, Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
const dealt = (extra = {}) => {
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 5, bigBlind: 10, dealerSeatIndex: 0, ...extra });
Betting.beginHand(state);
return state;
};
describe("the money", () => {
test("goes back to the people who put it in", () => {
const state = dealt();
const before = state.seats.map(p => Betting.stackOf(state, p) + Betting.committed(p));
Betting.takeHandBack(state);
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before);
});
test("including calls and raises, not just the blinds", () => {
const state = dealt();
Betting.raiseTo(state, state.actingSeatIndex, 40);
Betting.call(state, state.actingSeatIndex);
assert.ok(Betting.potSoFar(state) > 50, "there is real money in the middle");
const taken = Betting.takeHandBack(state);
assert.equal(taken.total, 95, "blinds and both wagers");
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), [200, 200, 200, 200, 200, 200]);
});
test("and across streets, once chips have been swept into hand totals", () => {
const state = dealt();
[3, 4, 5, 0, 1, 2].forEach(seat => {
if (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
});
Betting.closeStreet(state);
assert.ok(state.seats.some(p => (Number(p.amountOverride) || 0) > 0), "the flop's money is banked");
Betting.takeHandBack(state);
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), [200, 200, 200, 200, 200, 200]);
assert.deepEqual(state.seats.map(p => p.amountOverride), [null, null, null, null, null, null]);
});
test("the table has exactly the chips it started with", () => {
const fresh = makeTable({ seatCount: 6, stack: 200, smallBlind: 5, bigBlind: 10 });
const total = chipsOnTable(fresh);
const state = dealt();
Betting.raiseTo(state, state.actingSeatIndex, 40);
Betting.takeHandBack(state);
assert.equal(chipsOnTable(state), total);
});
test("nothing is thrown away — the discard pile doesn't move", () => {
const state = dealt();
state.discarded = 8;
Betting.takeHandBack(state);
assert.equal(state.discarded, 8, "a misdeal is not an abandoned pot");
});
test("it says what went back and to whom", () => {
const state = dealt();
const taken = Betting.takeHandBack(state);
assert.equal(taken.returned.length, 2, "the two blinds");
assert.deepEqual(taken.returned.map(r => r.amount).sort((a, b) => a - b), [5, 10]);
assert.ok(taken.returned.every(r => r.name), "named, because the dealer says it out loud");
});
});
describe("the table afterwards", () => {
test("is waiting to deal the same hand again", () => {
const state = dealt();
Betting.takeHandBack(state);
assert.equal(state.bettingOpen, false, "the dealer says when");
assert.equal(state.street, "preflop");
assert.equal(state.actingSeatIndex, null);
assert.equal(state.currentBet, 0);
});
test("keeps the button where it was — a misdeal is not the next hand", () => {
const state = dealt({ dealerSeatIndex: 3 });
const handNumber = state.handNumber;
Betting.takeHandBack(state);
assert.equal(state.dealerSeatIndex, 3);
assert.equal(state.handNumber, handNumber, "no hand has been played");
assert.equal((state.handHistory || []).length, 0, "and none is logged");
});
test("keeps the straddle, which was announced and still stands", () => {
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 5, bigBlind: 10 });
state.straddleCount = 1;
Betting.beginHand(state);
assert.deepEqual(state.postedStraddles, [3]);
Betting.takeHandBack(state);
assert.equal(state.straddleCount, 1, "still on for the redeal");
assert.deepEqual(state.postedStraddles, [], "but nothing is out there yet");
Betting.beginHand(state);
assert.deepEqual(state.postedStraddles, [3], "and it goes out again");
});
test("clears the cards and the folds, because none of it happened", () => {
const state = dealt();
state.seats[3].cards = ["As", "Kd"];
Betting.fold(state, 3);
Betting.takeHandBack(state);
assert.deepEqual(state.seats[3].cards, ["", ""]);
assert.equal(state.seats[3].folded, false);
assert.ok(state.seats.every(p => !p.hasActed));
});
test("deals again cleanly, with the same blinds on the same players", () => {
const state = dealt();
const first = state.seats.map(p => Betting.committed(p));
Betting.takeHandBack(state);
Betting.beginHand(state);
assert.deepEqual(state.seats.map(p => Betting.committed(p)), first);
});
});
describe("the bank still balances", () => {
test("through a misdeal and the redeal after it", () => {
const state = dealt();
state.seats.forEach(p => { p.buyIns = 200; });
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
Betting.raiseTo(state, state.actingSeatIndex, 60);
Betting.takeHandBack(state);
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
Betting.beginHand(state);
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.balanced, true);
assert.equal(bank.cashIn, 1200);
});
});
describe("taking back a hand nobody had money in", () => {
test("is harmless: no blinds, nothing moves", () => {
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 0, bigBlind: 0 });
Betting.beginHand(state);
const taken = Betting.takeHandBack(state);
assert.equal(taken.total, 0);
assert.deepEqual(taken.returned, []);
assert.equal(state.bettingOpen, false);
});
});
+133
View File
@@ -0,0 +1,133 @@
// One question, one answer.
//
// The round before this found the bank check and the standings reading `seat.stack`
// where the felt reads the table's starting stack — two parts of the tool disagreeing
// about how much money is on the table. This is the sweep that followed: everywhere
// else the same two questions get asked, asked the same way.
//
// "What is this seat holding?" -> HoldemState.chipsHeldBy
// "Is anybody in this chair?" -> HoldemState.seatInPlay
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { HoldemState, Betting, SettleUp } = require("./load");
const openedOnTheDefault = (stack = 200) => {
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, 6);
state.defaultStack = stack;
["Ann", "Ben", "Cal"].forEach((name, i) => {
state.seats[i].name = name;
state.seats[i].inHand = true;
state.seats[i].buyIns = stack;
});
return state;
};
describe("what a seat is holding", () => {
test("is the same number the felt plays with", () => {
const state = openedOnTheDefault();
state.seats.forEach((player, i) => {
if (i > 2) return;
assert.equal(HoldemState.chipsHeldBy(state, player), Betting.stackOf(state, player));
});
});
test("and is nothing at all for an empty chair", () => {
const state = openedOnTheDefault();
// Betting's own answer for an empty chair is the table default, because it is
// only ever asked about seats that are in a hand. The money side has to ask
// about all ten chairs, so it checks whether anybody is in them first.
assert.equal(Betting.stackOf(state, state.seats[5]), 200);
assert.equal(HoldemState.chipsHeldBy(state, state.seats[5]), 0);
});
test("counts a stack the dealer typed over the table's default", () => {
const state = openedOnTheDefault();
state.seats[0].stack = 75;
assert.equal(HoldemState.chipsHeldBy(state, state.seats[0]), 75);
});
});
describe("is anybody in this chair", () => {
const empty = () => HoldemState.createDefault().seats[0];
test("an untouched chair is empty", () => {
assert.equal(HoldemState.seatInPlay(empty()), false);
assert.equal(HoldemState.seatInPlay(null), false);
});
test("a name, chips, cash, a bet or a dealt hand all count", () => {
[
["a name", p => { p.name = "Ann"; }],
["chips", p => { p.stack = 50; }],
["cash taken", p => { p.buyIns = 100; }],
["a bet in front of them", p => { p.committed = 5; }],
["money already swept in", p => { p.amountOverride = 20; }],
["a hand dealt to them", p => { p.inHand = true; }],
].forEach(([what, mark]) => {
const seat = empty();
mark(seat);
assert.equal(HoldemState.seatInPlay(seat), true, what);
});
});
test("whitespace is not a name", () => {
const seat = empty();
seat.name = " ";
assert.equal(HoldemState.seatInPlay(seat), false);
});
});
describe("the parts that used to disagree", () => {
test("the open seats and the seat count see the same empty chairs", () => {
const state = openedOnTheDefault();
// Three players, six chairs.
assert.deepEqual(HoldemState.openSeats(state), [3, 4, 5]);
const occupied = state.seats.filter(p => HoldemState.seatInPlay(p)).length;
assert.equal(occupied + HoldemState.openSeats(state).length, state.seats.length);
});
test("a chair holding only a dealt hand is not offered to the waiting list", () => {
// The old test looked at name, buy-ins and stack. A seat marked in-hand with
// none of those would have been handed to the next person in the queue, on top
// of whoever was already playing it.
const state = openedOnTheDefault();
state.seats[4].inHand = true;
assert.deepEqual(HoldemState.openSeats(state), [3, 5]);
HoldemState.addToWaitingList(state, "Priya");
const result = HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 4);
assert.equal(result.ok, false);
});
test("the bank, the standings and the settle-up all price the night the same way", () => {
const state = openedOnTheDefault();
state.seats[0].stack = 320; // Ann is up
state.seats[1].stack = 80; // Ben is down
const bank = HoldemState.bankReconciliation(state);
const standings = HoldemState.sessionStandings(state);
const chipsFromStandings = standings
.filter(row => !row.cashedOut)
.reduce((sum, row) => sum + row.chipsInFront, 0);
assert.equal(bank.chipsInPlay, chipsFromStandings, "same chips on the table");
assert.equal(bank.balanced, true);
// And the payments the dealer is told to make add up to the same money.
const plan = SettleUp.plan(standings);
const owed = standings.reduce((sum, row) => sum + Math.max(0, row.cashPosition), 0);
const paid = plan.payments.reduce((sum, row) => sum + row.amount, 0);
assert.equal(paid + (plan.shortBy || 0) - (plan.overBy || 0), owed);
});
test("a player still holding the table's starting stack is in all three", () => {
const state = openedOnTheDefault();
const standings = HoldemState.sessionStandings(state);
assert.equal(standings.length, 3);
assert.deepEqual(standings.map(row => row.chipsInFront), [200, 200, 200]);
assert.equal(HoldemState.bankReconciliation(state).chipsInPlay, 600);
assert.deepEqual(SettleUp.plan(standings).payments, [], "everyone is even, so nobody pays anybody");
});
});
+218
View File
@@ -0,0 +1,218 @@
// What a shut sidebar panel says about itself.
//
// The sidebar is eight <details>, every one exactly 60px tall with nothing in it but
// its own name — 480px of a phone's screen and 480px of a 660px desktop column, saying
// "Standings", "Hands", "Waiting list" and nothing else. Whether the box balances,
// whether anyone is waiting for a seat, how many hands have been played: the page had
// all three worked out and charged a tap for each of them, on rows two thirds empty.
//
// So each one now carries the fact you would have opened it for. Two rules hold this
// together, and both are checked here.
//
// A glance appears only when it has something a dealer would act on. Eight rows that
// always say something are eight rows that say nothing again, and a row reading
// "Nobody waiting" all night is the clutter this went in to remove.
//
// And a glance never says something the panel under it would contradict. It is the
// panel's own sentence with the explanation taken off, so it has to be read off the
// same state — never off the panel's DOM, which for a shut <details> isn't rendered
// at all.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Render, HoldemState, makeTable } = require("./load");
const glances = state => Render.panelGlances(state);
const textOf = (state, id) => (glances(state)[id] || {}).text;
// A table part-way through a night: money on it, so the bank check has an opinion.
const night = () => {
const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3, 4], stack: 200,
smallBlind: 5, bigBlind: 10 });
state.seats.slice(0, 5).forEach(p => { p.buyIns = 200; });
return state;
};
describe("the rows that always have something to say", () => {
test("the table's own shape and stakes", () => {
assert.equal(textOf(night(), "glanceTableSetup"), "9 seats · $5/$10");
});
test("and the stakes drop out rather than reading as free", () => {
// A table set up with no blinds yet is a real state — it is what the panel
// opens on — and "9 seats · $0/$0" would be a claim about the game rather
// than an admission that nobody has said yet.
const state = night();
state.smallBlind = 0;
state.bigBlind = 0;
assert.equal(textOf(state, "glanceTableSetup"), "9 seats");
});
test("the chip values, as a range rather than a count of rows", () => {
// "Are the values right for tonight's set" is the question this answers.
// Five colours is the answer to a different one.
assert.equal(textOf(night(), "glanceChips"), "5 colours · $1–$500");
});
test("with a chip nobody has priced yet left out of the range", () => {
// A blank row in the legend is a chip the dealer hasn't got to, not a
// zero-dollar chip — and letting it in would read the range as starting at $0.
const state = night();
state.chipLegend.push({ id: "pink", color: "#ff88aa", value: 0 });
assert.equal(textOf(state, "glanceChips"), "5 colours · $1–$500");
});
test("and one chip is a value, not a range of one", () => {
const state = night();
state.chipLegend = [{ id: "red", color: "#c0392b", value: 5 }];
assert.equal(textOf(state, "glanceChips"), "1 colour · $5");
});
});
describe("the bank check, which is the one worth a colour", () => {
test("says so when the cash covers the chips", () => {
const state = night();
const { text, tone } = glances(state).glanceStandings;
assert.equal(text, "Balanced");
assert.equal(tone, "ok");
});
test("and names the gap when it doesn't", () => {
// Chips on the table that nobody paid for. The glance is the alarm and the
// amount; which seat is holding them is a paragraph, and that is the half
// that stays behind the tap.
const state = night();
state.seats[0].buyIns = 100;
const { text, tone } = glances(state).glanceStandings;
assert.equal(text, "Off by $100");
assert.equal(tone, "warn");
});
test("the same way round when the cash is the side that's over", () => {
// One word covers both signs on purpose: the number and the alarm are what a
// dealer acts on, and "short" against "over" is a direction they would have to
// hold in their head to read. The panel says which way in a sentence.
const state = night();
state.seats[0].buyIns = 400;
const { text, tone } = glances(state).glanceStandings;
assert.equal(text, "Off by $200");
assert.equal(tone, "warn");
});
test("and stays quiet over a table nobody has bought into", () => {
// The panel hides its own check here for the same reason. A glance reading
// "Balanced" over an empty table is answering for a count never taken.
const state = makeTable({ seatCount: 9, seated: [], smallBlind: 5, bigBlind: 10 });
state.seats.forEach(p => { p.stack = null; p.buyIns = 0; });
assert.equal(textOf(state, "glanceStandings"), "");
assert.equal(glances(state).glanceStandings.tone, "");
});
test("and agrees with the sentence the panel would print", () => {
// The two are read off one call apiece and must never disagree — a glance
// saying "Balanced" over a panel saying "$40 more in chips than cash taken"
// is worse than no glance at all.
[0, 100, 400].forEach(buyIn => {
const state = night();
state.seats[0].buyIns = buyIn;
const bank = HoldemState.bankReconciliation(state);
const { text, tone } = glances(state).glanceStandings;
assert.equal(tone === "ok", bank.balanced, `buy-in ${buyIn}`);
if (!bank.balanced) {
assert.ok(text.includes(String(Math.abs(bank.difference))),
`${text} doesn't carry the panel's own ${bank.difference}`);
}
});
});
});
describe("the rows that stay quiet until they have news", () => {
test("nobody waiting, no note, no hands played yet", () => {
const state = night();
assert.equal(textOf(state, "glanceWaiting"), "");
assert.equal(textOf(state, "glanceHands"), "");
assert.equal(textOf(state, "glanceNotes"), "");
});
test("and speak up the moment they do", () => {
const state = night();
state.waitingList = [{ id: "w1", name: "Nadia" }, { id: "w2", name: "Owen" }];
state.handHistory = [{ handNumber: 1, pot: 120, payouts: [], ledger: [] }];
assert.equal(textOf(state, "glanceWaiting"), "2 waiting");
assert.equal(textOf(state, "glanceHands"), "1 hand");
});
test("counting one of something as one of it", () => {
const state = night();
state.waitingList = [{ id: "w1", name: "Nadia" }];
state.handHistory = [{ handNumber: 1, pot: 120, payouts: [], ledger: [] },
{ handNumber: 2, pot: 80, payouts: [], ledger: [] }];
assert.equal(textOf(state, "glanceWaiting"), "1 waiting");
assert.equal(textOf(state, "glanceHands"), "2 hands");
});
test("and a note shows which note it is, not that there is one", () => {
// "Has notes" costs a tap to learn anything, which is the whole failure this
// is fixing. The text is what identifies it; the row's own width ellipsises.
const state = night();
state.notes = "Sam owes Dee $20 from last night";
assert.equal(textOf(state, "glanceNotes"), "Sam owes Dee $20 from last night");
});
test("reading past the blank lines a note gets typed under", () => {
// Notes get written as a list — a heading, a blank line, then the thing. The
// first line of the string is regularly not the first line of the note.
const state = night();
state.notes = "\n \n Sam owes Dee $20\nand Ted owes Al $5";
assert.equal(textOf(state, "glanceNotes"), "Sam owes Dee $20");
});
test("and a note of nothing but whitespace is no note", () => {
const state = night();
state.notes = "\n\n \t \n";
assert.equal(textOf(state, "glanceNotes"), "");
});
});
describe("what the row is allowed to be", () => {
const ROWS = ["glanceTableSetup", "glanceStandings", "glanceWaiting",
"glanceHands", "glanceChips", "glanceNotes"];
test("every glance the sidebar renders is one this knows about", () => {
// The list drives the DOM writes, so a glance computed and never listed is a
// row that silently keeps its old text for the rest of the night.
const state = night();
assert.deepEqual(Object.keys(glances(state)).sort(), [...ROWS].sort());
assert.deepEqual([...Render.PANEL_GLANCES].sort(), [...ROWS].sort());
});
test("and none of them is a paragraph", () => {
// The row is 44px of a 320px column with a label already on it. Anything
// long enough to need the panel's room belongs in the panel.
const state = night();
state.waitingList = [{ id: "w1", name: "Nadia" }];
state.handHistory = [{ handNumber: 1, pot: 120, payouts: [], ledger: [] }];
ROWS.filter(id => id !== "glanceNotes").forEach(id => {
const text = textOf(state, id);
assert.ok(text.length <= 24, `${id} is ${text.length} chars: "${text}"`);
assert.ok(!text.includes("\n"), `${id} has a line break in it`);
});
});
test("including a note, which is trimmed to one line and left to the ellipsis", () => {
// The one glance whose length isn't ours to choose. It must still be a single
// line — a second one would make the row taller, and the row is what holds the
// sidebar to eight equal slabs.
const state = night();
state.notes = "x".repeat(400) + "\nsecond line";
const text = textOf(state, "glanceNotes");
assert.ok(!text.includes("\n"));
assert.equal(text, "x".repeat(400));
});
test("and a state that isn't there yet asks for nothing", () => {
// renderAll runs before the first load has finished on a cold open.
assert.deepEqual(Render.panelGlances(null), {});
assert.deepEqual(Render.panelGlances(undefined), {});
});
});

Some files were not shown because too many files have changed in this diff Show More