Funding without control — the model spec + waterfall reference implementation (#1081)
* docs: funding without control — notes, the waterfall, and the public ledger The plan-of-record spec for taking investment with no VC and no investor control: profit-participating notes in named series (capped multiple, no votes, no maturity), a 24-month seasoning period and 12-month reserve floor, a quarterly 60/25/15 waterfall to noteholders/team/commons, an independent verification panel funded senior as an operating cost, and a committed-data public ledger in the status-pages pattern. Status: designed, not adopted — gated on Irish legal + tax review before any series opens. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * tools: waterfall reference implementation + pinned vectors tools/waterfall.mjs is the runnable form of docs/funding.md § The waterfall: pure functions, integer cents, deterministic to the cent (largest-remainder allocation, ties by note id). test/waterfall-vectors.json pins the doc's worked example plus the awkward cases — rounding into the commons, unseasoned retention, floor-streak gating, seasoning month boundaries, cap fill + residue — and test/waterfall.test.mjs adds conservation/determinism/ immutability properties. Wired into npm test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web/status: interactive Investing tab — the funding model, ledger, and simulator funding.html joins the status pages (nav on all ten): the funding model in member language, a designed-not-adopted status callout, the algorithm's parameters read live from the module, the (empty until adoption) public ledger from funding.json, and an interactive simulator — sliders for raise / cap multiple / revenue / growth / costs driving a quarterly run of the REAL algorithm with a streams-vs-remaining-cap chart (crosshair + arrow-key navigation, yearly table view, honesty notes for never-repaid and runs-out-of-money scenarios). The reference implementation moves to web/status/waterfall.js so the page imports and runs the very file it serves — the simulator is the normative module, not a copy; tools/waterfall.mjs stays as the documented Node-side re-export (pinned by a surface-parity test). status-publish.sh uploads the three new files (waterfall.js with an explicit JS content-type — module imports fail on a guessed one). Chart palette is dataviz-validated for both modes; identity is never color alone (legend, direct labels, table view). Verified headless in light + dark: no pageerrors/console errors, sliders and keyboard crosshair exercised. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
21 files changed,
+1570
−5
deploy/status-publish.sh+8 −0docs/funding.md+277 −0docs/status.md+12 −1package.json+1 −1test/waterfall-vectors.json+269 −0test/waterfall.test.mjs+132 −0tools/waterfall.mjs+6 −0web/status/books.html+1 −0web/status/carbon.html+1 −0web/status/contributors.html+1 −0web/status/economics.html+1 −0web/status/funding.html+677 −0web/status/funding.json+8 −0web/status/hardening.html+1 −0web/status/index.html+1 −0web/status/milestones.html+1 −0web/status/prompts.html+1 −0web/status/security.html+1 −0web/status/ssh.html+1 −0web/status/status.css+4 −3web/status/waterfall.js+166 −0
diff --git a/deploy/status-publish.sh b/deploy/status-publish.sh| index da44bf9..0d2bdfc 100755 |
| --- a/deploy/status-publish.sh |
| +++ b/deploy/status-publish.sh |
| @@ -1225,6 +1225,14 @@ publish() { |
| # § Keeping the tier-economics page current). Republished as-is. |
| [ -f "$PAGES_DIR/economics.html" ] && upload "$PAGES_DIR/economics.html" economics.html "text/html; charset=utf-8" "max-age=300" |
| [ -f "$PAGES_DIR/economics.json" ] && upload "$PAGES_DIR/economics.json" economics.json "application/json" "max-age=300" |
| + # Investing: page + committed funding ledger + the waterfall reference |
| + # implementation (docs/funding.md). waterfall.js is the NORMATIVE |
| + # algorithm module — the page imports and runs the same file it serves, |
| + # so it must publish with an explicit JS content-type (module imports |
| + # fail on a guessed one). Republished as-is, like economics. |
| + [ -f "$PAGES_DIR/funding.html" ] && upload "$PAGES_DIR/funding.html" funding.html "text/html; charset=utf-8" "max-age=300" |
| + [ -f "$PAGES_DIR/funding.json" ] && upload "$PAGES_DIR/funding.json" funding.json "application/json" "max-age=300" |
| + [ -f "$PAGES_DIR/waterfall.js" ] && upload "$PAGES_DIR/waterfall.js" waterfall.js "text/javascript; charset=utf-8" "max-age=300" |
| [ -f "$PAGES_DIR/carbon.html" ] && upload "$PAGES_DIR/carbon.html" carbon.html "text/html; charset=utf-8" "max-age=300" |
| # The carbon tally is committed data (the built ledger) — republished as-is |
| # EXCEPT for a live `run` block injected here (build_carbon). Built untouched, |
diff --git a/docs/funding.md b/docs/funding.md| new file mode 100644 |
| index 0000000..e6364e0 |
| --- /dev/null |
| +++ b/docs/funding.md |
| @@ -0,0 +1,277 @@ |
| +# 🤖 Funding without control — notes, the waterfall, and the public ledger |
| + |
| +> **Status: designed, not adopted.** No notes exist and no money has been |
| +> taken under this model. This document is the plan of record for how outside |
| +> investment would enter, sit, and leave — it becomes live only after an Irish |
| +> solicitor and tax advisor have reviewed the instrument (§ Regulatory |
| +> guardrails) and Series 1 is deliberately opened. Until then it is a spec, |
| +> and like every spec in this repo it is published so it can be argued with. |
| + |
| +Eleven takes no venture capital. The product is public-service *and* |
| +commercial — members pay for what things cost plus resilience |
| +(`docs/tier-economics.md`), and the books are already on the public record |
| +(`docs/status.md`). This document extends the same posture to investment: |
| +money may fund the work, but **money has no control**. There are no investor |
| +shares, no board seats, no votes, no exits. What an investor buys is a |
| +position in a published algorithm; what protects them is not power but |
| +transparency with teeth. |
| + |
| +The model in one paragraph: investors buy **profit-participating notes** in |
| +named series. The money sits through a **seasoning period** and behind a |
| +**reserve floor**, then a quarterly **waterfall** streams distributable |
| +surplus out — pro-rata to noteholders until each hits a fixed **cap |
| +multiple** and the note extinguishes, alongside fixed shares for the team and |
| +the commons. Every parameter, every computation input, and every payment is |
| +published in a machine-readable **ledger** on the status pages. The algorithm |
| +is versioned in this repo; each series pins the version it was sold under. An |
| +independent **verification panel** — funded senior to everyone as an |
| +operating cost — audits the claims: the encryption architecture, the |
| +published economics, and whether the money actually moved the way this |
| +document says it must. |
| + |
| +## The one rule here: money has no control |
| + |
| +- Notes carry **no votes, no board seats, no vetoes, no information rights** |
| + beyond the public ledger everyone already has. |
| +- The **only enforceable investor rights are the transparency promises**: |
| + that the ledger publishes, that the verification panel stays funded, and |
| + that distributions follow the pinned algorithm. Breach of those — and |
| + nothing else — gives noteholders a remedy (§ Events of default). |
| +- The company can always refuse money; money can never redirect the mission. |
| + Mission changes sit behind the guardian's golden share (§ Entities), which |
| + no amount of investment can buy. |
| +- Symmetrically, **the company cannot change a sold note**. Algorithm |
| + changes apply to future series only (§ Changing the algorithm). |
| + |
| +## Entities |
| + |
| +Two entities, one of which does almost nothing: |
| + |
| +- **The operating company** — an ordinary Irish private company (a DAC if we |
| + want the mission bound into a legal objects clause). It runs everything: |
| + the fleet, payroll, the raise itself. Deliberately boring to operate. |
| +- **The guardian** — a small passive entity holding a single **golden share** |
| + in the operating company's constitution. Its only powers are vetoes: sale |
| + or merger, weakening the E2EE architecture, gutting the licence, or |
| + applying an algorithm change retroactively to sold notes. It never trades, |
| + never employs, never touches product decisions. (Form — CLG, trust, or an |
| + existing steward foundation — is an open decision; the lesson from the |
| + earlier CLG is that the mission lock must never be the *operating* entity.) |
| + |
| +## The instrument: profit-participating notes |
| + |
| +A note is a simple contract, identical for every investor in a series: |
| + |
| +- **Principal** — the amount invested. |
| +- **Cap multiple** — the total the note can ever return, as a multiple of |
| + principal (default **2.0×**; a series fixes it anywhere in 1.5×–3.0×). |
| + No interest, no compounding, no fees: the cap is the whole return. |
| +- **No maturity date** — the note lives until its cap is reached through the |
| + waterfall, however long that takes. Distributions scale with revenue and |
| + nothing else. |
| +- **No security** — noteholders rank as unsecured creditors behind ordinary |
| + trade creditors and payroll. This is at-risk money and the note says so |
| + plainly. |
| +- **Algorithm pin** — the note names the git commit of this document it was |
| + sold under. That version governs the note forever. |
| + |
| +## Series |
| + |
| +Raises happen in **named series** (S1, S2, …), the way releases are minted. |
| +A series fixes, before it opens: its size ceiling, cap multiple, seasoning |
| +period, and algorithm version. All of that is published in the ledger when |
| +the series opens — terms are identical for every subscriber and there are no |
| +side letters, by rule. The company may refuse any subscription without |
| +reason (mission-alignment is allowed to be a judgment call), and every |
| +subscriber clears KYC/source-of-funds checks before funds are accepted. |
| + |
| +## Inflows and seasoning |
| + |
| +Subscribed money lands in the treasury and **sits**: |
| + |
| +- **Seasoning**: no distributions to a series' noteholders until the later |
| + of (a) **24 months** after the series closes and (b) the reserve floor |
| + holding for **two consecutive quarters**. New money funds building first; |
| + it cannot arrive and immediately start extracting. |
| +- Seasoning gates *noteholder* payments only — the verification retainer and |
| + ordinary operations are never gated. |
| + |
| +## The reserve floor |
| + |
| +Before anything is distributable, the treasury must hold **12 months of |
| +budgeted operating costs** (the budget itself is a published ledger input). |
| +Below the floor, nothing streams and the ledger says so. The floor is |
| +recomputed each quarter from the forward budget, so growth raises it. |
| + |
| +## The waterfall |
| + |
| +Computed once per quarter, from cash actually received and spent — no |
| +accrual games. Normative pseudocode; the prose around it explains intent, |
| +but a note incorporates this computation by reference: |
| + |
| +``` |
| +quarter(q): |
| + R = cash revenue received in q |
| + C = operating costs paid in q # includes the verification retainer |
| + F = reserve floor top-up needed # to reach 12 months forward budget |
| + D = R − C − F # distributable surplus |
| + |
| + if D ≤ 0: publish inputs; distribute nothing |
| + else: |
| + investors = 0.60 × D # pro-rata by remaining cap, seasoned series only |
| + team = 0.25 × D # the profit-share pool |
| + commons = 0.15 × D # price cuts, free-tier subsidy, aligned grants |
| + |
| + each note's payment = investors × (note remaining cap ÷ Σ remaining caps) |
| + a payment reduces the note's remaining cap; at zero the note extinguishes |
| + |
| + rounding: the investor and team pools round down to the cent — leftover |
| + cents always land with the commons, never the money |
| + if no note is seasoned: the investor pool is retained in the treasury |
| + (never redirected to the other pools; team and commons still flow) |
| + if the pool exceeds every remaining cap: notes fill and extinguish, and |
| + the excess is published as residue — its destination is a decision made |
| + and published at the time, like the post-extinguishment fold below |
| + when all notes are extinguished: investors' 60% folds into commons and team |
| + (35% / 25%), or into lower prices — decided and published at the time |
| +``` |
| + |
| +Notes on intent: |
| + |
| +- **Pro-rata by remaining cap**, not by principal — so early series aren't |
| + crowded out by later, larger ones, and every note converges on its cap at |
| + the same relative pace. |
| +- **The commons share is accounted publicly**: each quarter's ledger entry |
| + says where the 15% went (which prices were cut, what was granted, to whom). |
| +- The split constants (60/25/15), the cap-multiple range, seasoning, and the |
| + reserve months are **parameters of the algorithm version** — changing any |
| + of them is a new version (§ Changing the algorithm). |
| + |
| +A worked quarter, for scale honesty: Series 1 raises €300k at 2.0× — a €600k |
| +lifetime obligation ceiling. A quarter brings €40k revenue, €28k costs, €4k |
| +reserve top-up → D = €8k → €4,800 to noteholders, €2,000 to the team pool, |
| +€1,200 to the commons. Remaining caps fall to €595,200. At that pace the cap |
| +is decades away — the honest reading is that **noteholders are paid back at |
| +the speed the product actually earns**, and possibly never in full. This is |
| +patient, at-risk money by construction, and the offer documents say exactly |
| +that. |
| + |
| +## Verification: the assurance panel |
| + |
| +The credibility of every public claim — "the server holds only ciphertext", |
| +"the price is the cost plus resilience", "the money moved as published" — |
| +rests on someone independent checking. The panel is what the raise |
| +structurally guarantees: |
| + |
| +- **Funding is senior by construction**: the retainer is an operating cost |
| + (inside `C`), never a waterfall share — it cannot be starved by tuning the |
| + splits. It is prefunded **12 months ahead** in a segregated account, |
| + topped up quarterly; if the balance ever drops below 12 months, that is an |
| + **assurance lapse**, published as a flag on the status pages the day it |
| + happens. |
| +- **Independence is contractual**: at least three members; the company |
| + appoints at most a minority; members cannot be removed, and the retainer |
| + cannot be reduced, in response to findings. |
| +- **Output is public**: at least annually, unredacted after a fixed 30-day |
| + remediation window — (1) an architecture audit of the E2EE claims |
| + (`docs/no-plaintext.md`, the security ledger), (2) a ledger audit (do the |
| + published numbers reconcile to bank statements), (3) an economics audit |
| + (are the published allowances the enforced ones — |
| + `docs/tier-economics.md`), and (4) an **algorithm-compliance audit**: were |
| + the quarter's distributions the pinned algorithm's outputs for the |
| + published inputs. |
| + |
| +## The ledger |
| + |
| +The money's public record, in the committed-data pattern the status pages |
| +already use (`docs/status.md` — carbon, economics, contributors): |
| + |
| +- **`web/status/funding.json`** — committed data, republished as-is by the |
| + box's status timer alongside `funding.html` (the **Investing** tab of the |
| + status pages, which also carries an interactive simulator that imports and |
| + runs the served algorithm module over a chosen scenario). Three arrays: |
| + - `series[]` — id, opened/closed dates, size, cap multiple, seasoning, |
| + algorithm version (git commit). |
| + - `notes[]` — **pseudonymous** note ids with principal, remaining cap, |
| + series. KYC identities stay private (they are personal data and buy no |
| + influence anyway); amounts and movements are public. |
| + - `quarters[]` — every input (`R`, `C`, `F`, the forward budget behind the |
| + floor) and every output (per-note payments, team, commons, and where the |
| + commons went), plus reserve and retainer balances. |
| +- **All ledger amounts are integer cents** — no floats near money, and every |
| + computation is deterministic to the cent (remainder cents are assigned by |
| + largest fractional remainder, ties broken by note id). |
| +- **The algorithm ships as code**: `web/status/waterfall.js`, a pure function |
| + from `funding.json` to the quarter's payments, Node-tested against pinned |
| + vectors (`test/waterfall.test.mjs` + `test/waterfall-vectors.json`) in the |
| + same style as `format.js` (`tools/waterfall.mjs` re-exports it for |
| + Node-side use). It lives in the published status directory on purpose: the |
| + Investing page serves it *and* imports it, so the public simulator runs the |
| + normative module rather than describing a copy. Anyone can re-run it: **an |
| + audit is a re-execution**. Legally the pinned prose governs; a divergence |
| + between prose and code is a defect fixed in code, in public. |
| + |
| +## Events of default — the teeth |
| + |
| +The note's only covenants are the transparency promises, so those are the |
| +only defaults. If any of these holds and is not cured within **90 days**: |
| + |
| +1. the ledger stops publishing, or publishes figures the verification panel |
| + finds irreconcilable with bank records; |
| +2. the assurance lapse flag stands uncured (the panel defunded); or |
| +3. a distribution is made off-algorithm, or an algorithm change is applied |
| + retroactively to a sold note — |
| + |
| +then every note's **unreturned principal becomes repayable**. That is the |
| +entire remedy: no step-in rights, no board seats, no control even in |
| +default. Investors can force the money back out; they can never take the |
| +wheel. |
| + |
| +## Wind-down |
| + |
| +- **Solvent wind-down**: after all obligations, remaining treasury goes |
| + first to notes up to *unreturned principal* (not cap), then to the |
| + commons — published, like everything else. The golden share blocks |
| + sale-for-parts as an exit. |
| +- **Insolvency**: notes rank as unsecured, behind trade creditors and |
| + payroll. The offer documents say so in plain language. |
| + |
| +## Changing the algorithm |
| + |
| +This document is versioned in git; **a note pins the commit it was sold |
| +under and that version governs it forever**. Changing parameters or |
| +mechanics is a new version that can apply only to series opened after it — |
| +enforced twice: contractually in the note, and by the guardian's golden-share |
| +veto on retroactive application. (The deploy-pinning instinct, applied to |
| +money.) |
| + |
| +## Regulatory guardrails |
| + |
| +The structure must stay inside these lines, and adoption is gated on |
| +professional review of each: |
| + |
| +- **Prospectus rules** — notes are securities; offers stay inside an EU |
| + Prospectus Regulation exemption (the Irish national threshold — confirm |
| + the current figure with the solicitor — and/or ≤149 non-qualified persons |
| + per member state, and/or qualified investors only). Series size ceilings |
| + are set to keep each raise inside the exemption used. |
| +- **Not deposit-taking** — the money is at-risk with contingent, |
| + algorithm-determined returns and is never repayable on demand (default |
| + remedies excepted). Nothing in the copy or the contract may resemble a |
| + deposit. |
| +- **Tax** — profit-participating interest can be treated as a distribution |
| + (non-deductible) in Ireland, and withholding may apply to payments. The |
| + waterfall's economics are only real once the instrument's tax treatment is |
| + nailed down; this is the advisor's first question. |
| +- **Per-series sign-off** — every series gets legal review before it opens, |
| + not just Series 1. |
| + |
| +## What this is not |
| + |
| +- Not VC: no equity, no exit, capped upside, revenue-paced returns that may |
| + take decades or never complete. It selects for patient, mission-aligned |
| + money — customers, peers, steward-ownership funds — and that is the point. |
| +- Not a bank: money in is at risk, and "sits for a while" is seasoning, not |
| + safekeeping. |
| +- Not governance: the raise funds the work and the checking of the work. The |
| + mission is locked where money can't reach it. |
diff --git a/docs/status.md b/docs/status.md| index 32e868f..9e237a5 100644 |
| --- a/docs/status.md |
| +++ b/docs/status.md |
| @@ -1,4 +1,4 @@ |
| -# 🤖 Public status dashboards — fleet, books, economics, security, carbon, contributors, prompts & SSH access |
| +# 🤖 Public status dashboards — fleet, books, economics, investing, security, carbon, contributors, prompts & SSH access |
| World-readable pages, published every 5 minutes from the router box: the |
| running half of #550 Part 11/12's transparency direction (the append-only |
| @@ -25,6 +25,17 @@ public *ledger* repo is still roadmap — see `docs/planet-scale.md`). |
| hand-derived snapshot of `docs/tier-economics.md` (the repo is private; |
| this page is what makes the doc actually public), republished as-is. See |
| *Keeping the tier-economics page current* below. |
| +- **Investing** (`funding.html` + `funding.json` + `waterfall.js`) — the |
| + funding model (`docs/funding.md` — the repo is private; this page is what |
| + makes the model public) and its open ledger. The JSON is **committed data** |
| + like economics — empty (`adopted: false`) until the model passes legal + |
| + tax review and a series deliberately opens; thereafter every series, note |
| + (pseudonymous ids only), and quarterly waterfall computation publishes |
| + here. `waterfall.js` is the algorithm's **normative reference |
| + implementation**, served from the same directory so the page's interactive |
| + simulator imports and runs the very file it publishes — an audit is a |
| + re-execution (Node-side consumers use the `tools/waterfall.mjs` re-export; |
| + vectors `test/waterfall-vectors.json`). |
| - **The carbon tally** (`carbon.html` + `carbon.json`) — the running estimate |
| of the CO₂e cost of the LLM tokens spent *building* Eleven, as a cumulative |
| chart + ledger with everyday equivalents. Unlike the other two feeds, the |
diff --git a/package.json b/package.json| index 13ce97e..fa3ce79 100644 |
| --- a/package.json |
| +++ b/package.json |
| @@ -3,6 +3,6 @@ |
| "private": true, |
| "type": "module", |
| "scripts": { |
| - "test": "node --test test/module-refs.test.mjs && node test/apps-dir.test.mjs && node test/crypto.test.mjs && node test/vaultcrypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/sandbox-theme.test.mjs && node test/status-contract.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node test/stripe-band-prices.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs && node home/test/fx.test.mjs" |
| + "test": "node --test test/module-refs.test.mjs && node test/apps-dir.test.mjs && node test/crypto.test.mjs && node test/vaultcrypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/sandbox-theme.test.mjs && node test/status-contract.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node test/stripe-band-prices.test.mjs && node test/waterfall.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs && node home/test/fx.test.mjs" |
| } |
| } |
diff --git a/test/waterfall-vectors.json b/test/waterfall-vectors.json| new file mode 100644 |
| index 0000000..c1323de |
| --- /dev/null |
| +++ b/test/waterfall-vectors.json |
| @@ -0,0 +1,269 @@ |
| +{ |
| + "_readme": "Pinned vectors for the funding waterfall (docs/funding.md § The waterfall). Amounts are integer cents. These are the spec, not snapshots: if tools/waterfall.mjs disagrees, fix the implementation (or version the algorithm), never the vector. Run: node test/waterfall.test.mjs", |
| + "vectors": [ |
| + { |
| + "name": "doc worked example — €40k revenue quarter over Series 1", |
| + "ledger": { |
| + "series": [{ "id": "S1", "closedAt": "2026-09-30", "capMultiple": 2, "seasoningMonths": 24 }], |
| + "notes": [ |
| + { "id": "S1-001", "series": "S1", "principal": 20000000, "remainingCap": 40000000 }, |
| + { "id": "S1-002", "series": "S1", "principal": 10000000, "remainingCap": 20000000 } |
| + ], |
| + "quarters": [ |
| + { "id": "2028-Q2", "floorMet": true }, |
| + { "id": "2028-Q3", "floorMet": true } |
| + ] |
| + }, |
| + "quarter": { "id": "2028-Q4", "asOf": "2028-12-31", "revenue": 4000000, "costs": 2800000, "reserveTopUp": 400000, "floorMet": true }, |
| + "expected": { |
| + "distributable": 800000, |
| + "distributed": true, |
| + "investorPool": 480000, |
| + "teamPool": 200000, |
| + "commonsPool": 120000, |
| + "payments": [ |
| + { "note": "S1-001", "amount": 320000 }, |
| + { "note": "S1-002", "amount": 160000 } |
| + ], |
| + "retained": 0, |
| + "residue": 0, |
| + "extinguished": [], |
| + "notes": [ |
| + { "id": "S1-001", "remainingCap": 39680000 }, |
| + { "id": "S1-002", "remainingCap": 19840000 } |
| + ] |
| + } |
| + }, |
| + { |
| + "name": "negative surplus distributes nothing", |
| + "ledger": { |
| + "series": [{ "id": "S1", "closedAt": "2026-09-30", "capMultiple": 2, "seasoningMonths": 24 }], |
| + "notes": [{ "id": "S1-001", "series": "S1", "principal": 20000000, "remainingCap": 40000000 }], |
| + "quarters": [ |
| + { "id": "2028-Q2", "floorMet": true }, |
| + { "id": "2028-Q3", "floorMet": true } |
| + ] |
| + }, |
| + "quarter": { "id": "2028-Q4", "asOf": "2028-12-31", "revenue": 1000000, "costs": 2800000, "reserveTopUp": 0, "floorMet": false }, |
| + "expected": { |
| + "distributable": -1800000, |
| + "distributed": false, |
| + "investorPool": 0, |
| + "teamPool": 0, |
| + "commonsPool": 0, |
| + "payments": [], |
| + "retained": 0, |
| + "residue": 0, |
| + "extinguished": [], |
| + "notes": [{ "id": "S1-001", "remainingCap": 40000000 }] |
| + } |
| + }, |
| + { |
| + "name": "zero surplus distributes nothing", |
| + "ledger": { |
| + "series": [{ "id": "S1", "closedAt": "2026-09-30", "capMultiple": 2, "seasoningMonths": 24 }], |
| + "notes": [{ "id": "S1-001", "series": "S1", "principal": 20000000, "remainingCap": 40000000 }], |
| + "quarters": [ |
| + { "id": "2028-Q2", "floorMet": true }, |
| + { "id": "2028-Q3", "floorMet": true } |
| + ] |
| + }, |
| + "quarter": { "id": "2028-Q4", "asOf": "2028-12-31", "revenue": 2800000, "costs": 2800000, "reserveTopUp": 0, "floorMet": true }, |
| + "expected": { |
| + "distributable": 0, |
| + "distributed": false, |
| + "payments": [], |
| + "retained": 0, |
| + "residue": 0 |
| + } |
| + }, |
| + { |
| + "name": "rounding cents land with the commons", |
| + "ledger": { |
| + "series": [{ "id": "S1", "closedAt": "2026-09-30", "capMultiple": 2, "seasoningMonths": 24 }], |
| + "notes": [{ "id": "S1-001", "series": "S1", "principal": 20000000, "remainingCap": 40000000 }], |
| + "quarters": [ |
| + { "id": "2028-Q2", "floorMet": true }, |
| + { "id": "2028-Q3", "floorMet": true } |
| + ] |
| + }, |
| + "quarter": { "id": "2028-Q4", "asOf": "2028-12-31", "revenue": 10001, "costs": 0, "reserveTopUp": 0, "floorMet": true }, |
| + "expected": { |
| + "distributable": 10001, |
| + "distributed": true, |
| + "investorPool": 6000, |
| + "teamPool": 2500, |
| + "commonsPool": 1501, |
| + "payments": [{ "note": "S1-001", "amount": 6000 }], |
| + "retained": 0, |
| + "residue": 0 |
| + } |
| + }, |
| + { |
| + "name": "remainder cents are deterministic — largest remainder, then note id", |
| + "ledger": { |
| + "series": [{ "id": "S1", "closedAt": "2026-09-30", "capMultiple": 2, "seasoningMonths": 24 }], |
| + "notes": [ |
| + { "id": "n-a", "series": "S1", "principal": 500, "remainingCap": 1000 }, |
| + { "id": "n-b", "series": "S1", "principal": 500, "remainingCap": 1000 }, |
| + { "id": "n-c", "series": "S1", "principal": 500, "remainingCap": 1000 } |
| + ], |
| + "quarters": [ |
| + { "id": "2028-Q2", "floorMet": true }, |
| + { "id": "2028-Q3", "floorMet": true } |
| + ] |
| + }, |
| + "quarter": { "id": "2028-Q4", "asOf": "2028-12-31", "revenue": 167, "costs": 0, "reserveTopUp": 0, "floorMet": true }, |
| + "expected": { |
| + "distributable": 167, |
| + "distributed": true, |
| + "investorPool": 100, |
| + "teamPool": 41, |
| + "commonsPool": 26, |
| + "payments": [ |
| + { "note": "n-a", "amount": 34 }, |
| + { "note": "n-b", "amount": 33 }, |
| + { "note": "n-c", "amount": 33 } |
| + ], |
| + "retained": 0, |
| + "residue": 0, |
| + "notes": [ |
| + { "id": "n-a", "remainingCap": 966 }, |
| + { "id": "n-b", "remainingCap": 967 }, |
| + { "id": "n-c", "remainingCap": 967 } |
| + ] |
| + } |
| + }, |
| + { |
| + "name": "unseasoned series is excluded from the pro-rata", |
| + "ledger": { |
| + "series": [ |
| + { "id": "S1", "closedAt": "2026-09-30", "capMultiple": 2, "seasoningMonths": 24 }, |
| + { "id": "S2", "closedAt": "2028-06-30", "capMultiple": 2, "seasoningMonths": 24 } |
| + ], |
| + "notes": [ |
| + { "id": "S1-001", "series": "S1", "principal": 20000000, "remainingCap": 40000000 }, |
| + { "id": "S2-001", "series": "S2", "principal": 10000000, "remainingCap": 20000000 } |
| + ], |
| + "quarters": [ |
| + { "id": "2028-Q2", "floorMet": true }, |
| + { "id": "2028-Q3", "floorMet": true } |
| + ] |
| + }, |
| + "quarter": { "id": "2028-Q4", "asOf": "2028-12-31", "revenue": 4000000, "costs": 2800000, "reserveTopUp": 400000, "floorMet": true }, |
| + "expected": { |
| + "distributed": true, |
| + "investorPool": 480000, |
| + "payments": [{ "note": "S1-001", "amount": 480000 }], |
| + "retained": 0, |
| + "notes": [ |
| + { "id": "S1-001", "remainingCap": 39520000 }, |
| + { "id": "S2-001", "remainingCap": 20000000 } |
| + ] |
| + } |
| + }, |
| + { |
| + "name": "no seasoned notes — investor pool retained, team and commons still flow", |
| + "ledger": { |
| + "series": [{ "id": "S2", "closedAt": "2028-06-30", "capMultiple": 2, "seasoningMonths": 24 }], |
| + "notes": [{ "id": "S2-001", "series": "S2", "principal": 10000000, "remainingCap": 20000000 }], |
| + "quarters": [ |
| + { "id": "2028-Q2", "floorMet": true }, |
| + { "id": "2028-Q3", "floorMet": true } |
| + ] |
| + }, |
| + "quarter": { "id": "2028-Q4", "asOf": "2028-12-31", "revenue": 4000000, "costs": 2800000, "reserveTopUp": 400000, "floorMet": true }, |
| + "expected": { |
| + "distributed": true, |
| + "investorPool": 480000, |
| + "teamPool": 200000, |
| + "commonsPool": 120000, |
| + "payments": [], |
| + "retained": 480000, |
| + "notes": [{ "id": "S2-001", "remainingCap": 20000000 }] |
| + } |
| + }, |
| + { |
| + "name": "a broken floor streak gates seasoning even when the calendar has passed", |
| + "ledger": { |
| + "series": [{ "id": "S1", "closedAt": "2026-09-30", "capMultiple": 2, "seasoningMonths": 24 }], |
| + "notes": [{ "id": "S1-001", "series": "S1", "principal": 20000000, "remainingCap": 40000000 }], |
| + "quarters": [ |
| + { "id": "2028-Q2", "floorMet": true }, |
| + { "id": "2028-Q3", "floorMet": false } |
| + ] |
| + }, |
| + "quarter": { "id": "2028-Q4", "asOf": "2028-12-31", "revenue": 4000000, "costs": 2800000, "reserveTopUp": 400000, "floorMet": true }, |
| + "expected": { |
| + "distributed": true, |
| + "investorPool": 480000, |
| + "payments": [], |
| + "retained": 480000 |
| + } |
| + }, |
| + { |
| + "name": "pool beyond every cap — notes fill, extinguish, and the excess is residue", |
| + "ledger": { |
| + "series": [{ "id": "S1", "closedAt": "2026-09-30", "capMultiple": 2, "seasoningMonths": 24 }], |
| + "notes": [ |
| + { "id": "S1-001", "series": "S1", "principal": 100000, "remainingCap": 200000 }, |
| + { "id": "S1-002", "series": "S1", "principal": 50000, "remainingCap": 100000 } |
| + ], |
| + "quarters": [ |
| + { "id": "2028-Q2", "floorMet": true }, |
| + { "id": "2028-Q3", "floorMet": true } |
| + ] |
| + }, |
| + "quarter": { "id": "2028-Q4", "asOf": "2028-12-31", "revenue": 4000000, "costs": 2800000, "reserveTopUp": 400000, "floorMet": true }, |
| + "expected": { |
| + "distributed": true, |
| + "investorPool": 480000, |
| + "payments": [ |
| + { "note": "S1-001", "amount": 200000 }, |
| + { "note": "S1-002", "amount": 100000 } |
| + ], |
| + "retained": 0, |
| + "residue": 180000, |
| + "extinguished": ["S1-001", "S1-002"], |
| + "notes": [ |
| + { "id": "S1-001", "remainingCap": 0 }, |
| + { "id": "S1-002", "remainingCap": 0 } |
| + ] |
| + } |
| + }, |
| + { |
| + "name": "seasoning boundary — one day short of 24 months is unseasoned", |
| + "ledger": { |
| + "series": [{ "id": "S1", "closedAt": "2026-09-30", "capMultiple": 2, "seasoningMonths": 24 }], |
| + "notes": [{ "id": "S1-001", "series": "S1", "principal": 20000000, "remainingCap": 40000000 }], |
| + "quarters": [ |
| + { "id": "2028-Q1", "floorMet": true }, |
| + { "id": "2028-Q2", "floorMet": true } |
| + ] |
| + }, |
| + "quarter": { "id": "2028-Q3", "asOf": "2028-09-29", "revenue": 4000000, "costs": 2800000, "reserveTopUp": 400000, "floorMet": true }, |
| + "expected": { |
| + "distributed": true, |
| + "payments": [], |
| + "retained": 480000 |
| + } |
| + }, |
| + { |
| + "name": "seasoning boundary — exactly 24 months is seasoned", |
| + "ledger": { |
| + "series": [{ "id": "S1", "closedAt": "2026-09-30", "capMultiple": 2, "seasoningMonths": 24 }], |
| + "notes": [{ "id": "S1-001", "series": "S1", "principal": 20000000, "remainingCap": 40000000 }], |
| + "quarters": [ |
| + { "id": "2028-Q1", "floorMet": true }, |
| + { "id": "2028-Q2", "floorMet": true } |
| + ] |
| + }, |
| + "quarter": { "id": "2028-Q3", "asOf": "2028-09-30", "revenue": 4000000, "costs": 2800000, "reserveTopUp": 400000, "floorMet": true }, |
| + "expected": { |
| + "distributed": true, |
| + "payments": [{ "note": "S1-001", "amount": 480000 }], |
| + "retained": 0 |
| + } |
| + } |
| + ] |
| +} |
diff --git a/test/waterfall.test.mjs b/test/waterfall.test.mjs| new file mode 100644 |
| index 0000000..6073f6b |
| --- /dev/null |
| +++ b/test/waterfall.test.mjs |
| @@ -0,0 +1,132 @@ |
| +// Tests for the funding waterfall (docs/funding.md). node test/waterfall.test.mjs |
| +// The pinned vectors in test/waterfall-vectors.json are the spec: a failure |
| +// means the implementation drifted from docs/funding.md, and the fix is the |
| +// code (or a deliberate new algorithm version), never the vector. |
| +import assert from "node:assert/strict"; |
| +import { readFileSync } from "node:fs"; |
| +import { waterfall, applyQuarter, splitSurplus, monthsBetween, SPLIT } from "../tools/waterfall.mjs"; |
| + |
| +let failures = 0; |
| +async function test(name, fn) { |
| + try { |
| + await fn(); |
| + console.log("PASS " + name); |
| + } catch (e) { |
| + console.log("FAIL " + name + " — " + (e && e.message)); |
| + failures++; |
| + } |
| +} |
| + |
| +const { vectors } = JSON.parse(readFileSync(new URL("./waterfall-vectors.json", import.meta.url), "utf8")); |
| + |
| +for (const v of vectors) { |
| + await test("vector: " + v.name, () => { |
| + const result = waterfall(v.ledger, v.quarter); |
| + for (const [key, want] of Object.entries(v.expected)) { |
| + assert.deepEqual(result[key], want, key); |
| + } |
| + }); |
| +} |
| + |
| +// Every distributed cent must be accounted for — nothing leaks, nothing is |
| +// minted. This holds for every vector, not just the hand-picked ones. |
| +await test("conservation: pools sum to D; investor pool = payments + retained + residue", () => { |
| + for (const v of vectors) { |
| + const r = waterfall(v.ledger, v.quarter); |
| + if (!r.distributed) { |
| + assert.equal(r.payments.length, 0, v.name); |
| + continue; |
| + } |
| + assert.equal(r.investorPool + r.teamPool + r.commonsPool, r.distributable, v.name); |
| + const paid = r.payments.reduce((s, p) => s + p.amount, 0); |
| + assert.equal(paid + r.retained + r.residue, r.investorPool, v.name); |
| + } |
| +}); |
| + |
| +await test("determinism: identical inputs produce identical outputs", () => { |
| + for (const v of vectors) { |
| + assert.deepEqual(waterfall(v.ledger, v.quarter), waterfall(v.ledger, v.quarter), v.name); |
| + } |
| +}); |
| + |
| +await test("waterfall never mutates the ledger it is given", () => { |
| + const v = vectors[0]; |
| + const before = JSON.stringify(v.ledger); |
| + waterfall(v.ledger, v.quarter); |
| + assert.equal(JSON.stringify(v.ledger), before); |
| +}); |
| + |
| +await test("applyQuarter folds the result back into a publishable ledger", () => { |
| + const v = vectors[0]; // the doc's worked example |
| + const { result, ledger } = applyQuarter(v.ledger, v.quarter); |
| + assert.deepEqual( |
| + ledger.notes.map((n) => ({ id: n.id, remainingCap: n.remainingCap })), |
| + result.notes, |
| + ); |
| + const q = ledger.quarters.at(-1); |
| + assert.equal(q.id, v.quarter.id); |
| + assert.equal(q.floorMet, true); |
| + assert.deepEqual(q.payments, result.payments); |
| + // The fold keeps everything an auditor needs to re-run the quarter. |
| + assert.equal(q.revenue, v.quarter.revenue); |
| + assert.equal(q.investorPool, result.investorPool); |
| +}); |
| + |
| +await test("splitSurplus: shares match SPLIT and rounding favours the commons", () => { |
| + assert.deepEqual(SPLIT, { investors: 60, team: 25, commons: 15 }); |
| + for (const D of [1, 2, 3, 99, 100, 10001, 800000]) { |
| + const { investors, team, commons } = splitSurplus(D); |
| + assert.equal(investors + team + commons, D, "D=" + D); |
| + assert.ok(commons >= Math.floor((D * SPLIT.commons) / 100), "D=" + D); |
| + } |
| +}); |
| + |
| +await test("monthsBetween counts whole calendar months", () => { |
| + assert.equal(monthsBetween("2026-09-30", "2028-09-29"), 23); |
| + assert.equal(monthsBetween("2026-09-30", "2028-09-30"), 24); |
| + assert.equal(monthsBetween("2026-09-30", "2028-12-31"), 27); |
| + assert.equal(monthsBetween("2026-01-31", "2026-02-28"), 0); |
| +}); |
| + |
| +// The implementation lives in web/status/waterfall.js (served by the public |
| +// Investing page, which imports and runs it); tools/waterfall.mjs is the |
| +// documented Node-side path. The shim must expose the full surface — this |
| +// file imports through it, and here we pin that nothing is lost. |
| +await test("tools/waterfall.mjs re-exports the served module's whole surface", async () => { |
| + const served = await import("../web/status/waterfall.js"); |
| + const shim = await import("../tools/waterfall.mjs"); |
| + assert.deepEqual(Object.keys(shim).sort(), Object.keys(served).sort()); |
| +}); |
| + |
| +// The committed public ledger (docs/funding.md § The ledger): the Investing |
| +// page renders whatever this holds, so its shape is a contract. |
| +await test("web/status/funding.json holds the ledger shape the page reads", () => { |
| + const d = JSON.parse(readFileSync(new URL("../web/status/funding.json", import.meta.url), "utf8")); |
| + assert.equal(typeof d.as_of, "string"); |
| + assert.equal(typeof d.adopted, "boolean"); |
| + for (const k of ["series", "notes", "quarters"]) assert.ok(Array.isArray(d[k]), k); |
| + // Until adoption, the ledger must stay empty — no notes may exist before |
| + // the model passes review and a series deliberately opens. |
| + if (!d.adopted) for (const k of ["series", "notes", "quarters"]) assert.equal(d[k].length, 0, k); |
| +}); |
| + |
| +// The Investing page must run the served module and read the committed |
| +// ledger — never carry a copy of either. |
| +await test("funding.html imports ./waterfall.js and fetches ./funding.json", () => { |
| + const html = readFileSync(new URL("../web/status/funding.html", import.meta.url), "utf8"); |
| + assert.ok(html.includes('from "./waterfall.js"')); |
| + assert.ok(html.includes('fetch("./funding.json"')); |
| + assert.ok(html.includes('<span aria-current="page">Investing</span>')); |
| +}); |
| + |
| +// Every status page links the Investing tab (the ten-item nav stays in step). |
| +await test("all status pages carry the Investing nav link", () => { |
| + for (const page of ["index", "books", "economics", "carbon", "contributors", |
| + "security", "prompts", "ssh", "hardening", "milestones"]) { |
| + const html = readFileSync(new URL("../web/status/" + page + ".html", import.meta.url), "utf8"); |
| + assert.ok(html.includes('"./funding.html"'), page); |
| + } |
| +}); |
| + |
| +console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| +process.exit(failures === 0 ? 0 : 1); |
diff --git a/tools/waterfall.mjs b/tools/waterfall.mjs| new file mode 100644 |
| index 0000000..e77ca9e |
| --- /dev/null |
| +++ b/tools/waterfall.mjs |
| @@ -0,0 +1,6 @@ |
| +// tools/waterfall.mjs — the documented Node-side path for the funding |
| +// waterfall (docs/funding.md). The implementation lives in |
| +// web/status/waterfall.js so the public Investing page serves and runs the |
| +// very module it publishes — an audit is a re-execution, in the reader's own |
| +// browser. One copy; this file only re-exports it. |
| +export * from "../web/status/waterfall.js"; |
diff --git a/web/status/books.html b/web/status/books.html| index bb2a7b3..4efa811 100644 |
| --- a/web/status/books.html |
| +++ b/web/status/books.html |
| @@ -46,6 +46,7 @@ |
| <a href="./index.html">Fleet</a> |
| <span aria-current="page">Income & Expenditure</span> |
| <a href="./economics.html">Economics</a> |
| + <a href="./funding.html">Investing</a> |
| <a href="./security.html">Security</a> |
| <a href="./carbon.html">Carbon use</a> |
| <a href="./contributors.html">Contributors</a> |
diff --git a/web/status/carbon.html b/web/status/carbon.html| index 431e990..905ea1a 100644 |
| --- a/web/status/carbon.html |
| +++ b/web/status/carbon.html |
| @@ -105,6 +105,7 @@ |
| <a href="./index.html">Fleet</a> |
| <a href="./books.html">Income & Expenditure</a> |
| <a href="./economics.html">Economics</a> |
| + <a href="./funding.html">Investing</a> |
| <a href="./security.html">Security</a> |
| <span aria-current="page">Carbon use</span> |
| <a href="./contributors.html">Contributors</a> |
diff --git a/web/status/contributors.html b/web/status/contributors.html| index cee5e1d..50daba0 100644 |
| --- a/web/status/contributors.html |
| +++ b/web/status/contributors.html |
| @@ -47,6 +47,7 @@ |
| <a href="./index.html">Fleet</a> |
| <a href="./books.html">Income & Expenditure</a> |
| <a href="./economics.html">Economics</a> |
| + <a href="./funding.html">Investing</a> |
| <a href="./security.html">Security</a> |
| <a href="./carbon.html">Carbon use</a> |
| <span aria-current="page">Contributors</span> |
diff --git a/web/status/economics.html b/web/status/economics.html| index 98165ae..4764811 100644 |
| --- a/web/status/economics.html |
| +++ b/web/status/economics.html |
| @@ -50,6 +50,7 @@ |
| <a href="./index.html">Fleet</a> |
| <a href="./books.html">Income & Expenditure</a> |
| <span aria-current="page">Economics</span> |
| + <a href="./funding.html">Investing</a> |
| <a href="./security.html">Security</a> |
| <a href="./carbon.html">Carbon use</a> |
| <a href="./contributors.html">Contributors</a> |
diff --git a/web/status/funding.html b/web/status/funding.html| new file mode 100644 |
| index 0000000..76258a2 |
| --- /dev/null |
| +++ b/web/status/funding.html |
| @@ -0,0 +1,677 @@ |
| +<!-- 🤖 Public Investing page: the funding model (docs/funding.md — the repo is |
| + private; this page is what makes the model public) and its open ledger. |
| + The JSON is COMMITTED data (web/status/funding.json — empty until the |
| + model is adopted and a series opens), republished as-is by |
| + deploy/status-publish.sh. The simulator imports ./waterfall.js — the |
| + normative reference implementation, served from this same directory — so |
| + the page RUNS the published algorithm rather than describing a copy of |
| + it. Self-contained otherwise: inline CSS/JS; the only requests are |
| + ./funding.json and ./waterfall.js (relative, same origin). |
| + Chart palette (violet/yellow/aqua + a dashed neutral reference line) is |
| + dataviz-validated for both modes; the light-mode contrast WARN on |
| + yellow/aqua is covered by the relief rule: legend, right-edge direct |
| + labels, and the table view below the chart. --> |
| +<!doctype html> |
| +<html lang="en"> |
| +<head> |
| +<meta charset="utf-8" /> |
| +<meta name="viewport" content="width=device-width, initial-scale=1" /> |
| +<meta name="color-scheme" content="light dark" /> |
| +<title>Eleven — investing</title> |
| +<link rel="icon" type="image/png" href="./icon-192.png" /> |
| +<link rel="stylesheet" href="./status.css" /> |
| +<style> |
| + /* Investing-only: the status callout, simulator controls, chart figure, and |
| + ledger tables. Everything shared lives in status.css. */ |
| + .callout { background: var(--surface); border: 1px solid var(--border); border-left: 4px solid var(--warn); border-radius: 12px; padding: 14px 16px; margin-bottom: 28px; } |
| + .callout .head { font-weight: 650; } |
| + .callout p { margin-top: 6px; font-size: 15px; } |
| + .callout.adopted { border-left-color: var(--good-mark); } |
| + |
| + ul.prose { padding-left: 20px; display: grid; gap: 8px; font-size: 15px; } |
| + ul.prose .lead-in { font-weight: 650; } |
| + |
| + /* The three pool hues — validated for light and dark against the status |
| + surfaces. The remaining-cap reference line is neutral ink, dashed: it is |
| + a bound, not a stream, so it never takes a categorical slot. */ |
| + :root { --pool-investors: #4a3aa7; --pool-team: #eda100; --pool-commons: #1baf7a; } |
| + @media (prefers-color-scheme: dark) { |
| + :root { --pool-investors: #9085e9; --pool-team: #c98500; --pool-commons: #199e70; } |
| + } |
| + |
| + .controls { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 14px 18px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; margin-bottom: 14px; } |
| + .ctl { display: grid; gap: 2px; } |
| + .ctl label { font-size: 13px; color: var(--ink-2); } |
| + .ctl output { font-weight: 600; font-variant-numeric: tabular-nums; } |
| + .ctl input[type="range"] { width: 100%; margin: 6px 0 0; accent-color: var(--series); } |
| + .ctl input[type="range"]:focus-visible { outline: 2px solid var(--series); outline-offset: 2px; border-radius: 4px; } |
| + |
| + .legend { display: flex; flex-wrap: wrap; gap: 6px 18px; font-size: 14px; margin: 0 0 8px; list-style: none; padding: 0; } |
| + .legend .chip { display: inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 6px; vertical-align: -1px; } |
| + .legend .chip.dash { height: 0; width: 16px; border-top: 3px dashed var(--ink-2); border-radius: 0; vertical-align: 3px; background: none; } |
| + |
| + figure { position: relative; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 12px; margin: 0 0 8px; } |
| + figure svg { display: block; width: 100%; height: auto; } |
| + figure svg:focus-visible { outline: 2px solid var(--series); outline-offset: 2px; border-radius: 8px; } |
| + figcaption { color: var(--ink-2); font-size: 13px; padding: 4px 8px 8px; } |
| + #tip { |
| + display: none; position: absolute; z-index: 1; pointer-events: none; |
| + background: var(--surface); border: 1px solid var(--border); border-radius: 8px; |
| + padding: 8px 10px; font-size: 13px; max-width: 260px; box-shadow: 0 2px 8px rgba(0,0,0,0.12); |
| + } |
| + #tip .big { font-size: 15px; font-weight: 650; } |
| + #tip .sub { color: var(--ink-2); font-variant-numeric: tabular-nums; } |
| + |
| + .tablewrap { overflow-x: auto; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; } |
| + table { border-collapse: collapse; width: 100%; font-size: 14px; } |
| + caption { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } |
| + th, td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--grid); } |
| + thead th { font-size: 13px; color: var(--ink-2); font-weight: 650; white-space: nowrap; } |
| + tbody tr:last-child th, tbody tr:last-child td { border-bottom: none; } |
| + tbody th { font-weight: 600; white-space: nowrap; } |
| + .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } |
| + |
| + details.tableview { margin-top: 8px; } |
| + details.tableview summary { cursor: pointer; font-size: 14px; font-weight: 600; padding: 4px 0; } |
| + details.tableview summary:focus-visible { outline: 2px solid var(--series); outline-offset: 2px; border-radius: 4px; } |
| + details.tableview .tablewrap { margin-top: 8px; } |
| + |
| + .empty { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; color: var(--ink-2); font-size: 14px; } |
| +</style> |
| +</head> |
| +<body> |
| +<div class="wrap"> |
| + <header> |
| + <img src="./icon-192.png" alt="" /> |
| + <div> |
| + <p class="brand">Eleven Messenger</p> |
| + <h1>Investing</h1> |
| + </div> |
| + </header> |
| + <nav class="status-nav" aria-label="Status"> |
| + <a href="./index.html">Fleet</a> |
| + <a href="./books.html">Income & Expenditure</a> |
| + <a href="./economics.html">Economics</a> |
| + <span aria-current="page">Investing</span> |
| + <a href="./security.html">Security</a> |
| + <a href="./carbon.html">Carbon use</a> |
| + <a href="./contributors.html">Contributors</a> |
| + <a href="./prompts.html">Prompt Ledger</a> |
| + <a href="./ssh.html">Access Ledger</a> |
| + <a href="./hardening.html">Fleet hardening</a> |
| + </nav> |
| + <p class="lede">How money can fund Eleven without owning it. Eleven takes no |
| + venture capital: investment buys a position in a published algorithm — capped |
| + returns, no shares, no votes, no board seats — and every parameter, input, |
| + and payment lands on this page. The simulator below runs the actual published |
| + algorithm in your browser.</p> |
| + <p class="asof">Ledger dated <time id="asof" datetime="">—</time></p> |
| + |
| + <p id="error" role="alert">Couldn’t load the funding ledger. It may be |
| + mid-publish — this page retries every minute.</p> |
| + |
| + <div class="callout" id="status-callout"> |
| + <p class="head" id="status-head">Status: designed, not adopted</p> |
| + <p id="status-body">No notes exist and no money has been taken. The model |
| + below is a published design — open so it can be argued with — and becomes |
| + live only after independent legal and tax review, and only then by a |
| + deliberately opened series. Nothing on this page is an offer.</p> |
| + </div> |
| + |
| + <section aria-labelledby="h-model"> |
| + <h2 id="h-model">The model in one minute</h2> |
| + <ul class="prose"> |
| + <li><span class="lead-in">Notes, not shares.</span> Investors buy |
| + profit-participating notes in named series — principal plus a fixed cap |
| + multiple, and the cap is the whole return. No interest, no maturity date, |
| + no equity, no exit.</li> |
| + <li><span class="lead-in">Money has no control.</span> Notes carry no |
| + votes, no board seats, no vetoes. The only enforceable investor rights |
| + are transparency promises: this ledger publishes, the independent |
| + verification panel stays funded, and distributions follow the published |
| + algorithm. Breach makes unreturned principal repayable — investors can |
| + force the money back out; they can never take the wheel.</li> |
| + <li><span class="lead-in">Money sits before it streams.</span> No |
| + distributions until a series has seasoned and the treasury holds a |
| + reserve floor of forward operating costs — new money builds first.</li> |
| + <li><span class="lead-in">An open algorithm decides the rest.</span> |
| + Each quarter, distributable surplus streams out by a fixed split — |
| + noteholders pro-rata until each cap fills and the note extinguishes, |
| + alongside the team pool and the commons (price cuts, free-tier subsidy, |
| + aligned grants).</li> |
| + <li><span class="lead-in">Verification is funded first.</span> An |
| + independent panel — funded as an operating cost, senior to every |
| + distribution — audits the claims: the encryption architecture, the |
| + published economics, this ledger against bank records, and whether each |
| + quarter’s payments were the algorithm’s outputs.</li> |
| + <li><span class="lead-in">The algorithm is versioned.</span> A note pins |
| + the version it was sold under, forever. Changes apply to future series |
| + only.</li> |
| + </ul> |
| + </section> |
| + |
| + <section aria-labelledby="h-params"> |
| + <h2 id="h-params">The algorithm’s parameters</h2> |
| + <p class="note explain">Read live from <a href="./waterfall.js">waterfall.js</a> — |
| + the reference implementation this page serves and runs. Changing any of |
| + these is a new algorithm version, applied to future series only.</p> |
| + <div class="tiles" id="param-tiles"></div> |
| + </section> |
| + |
| + <section aria-labelledby="h-sim"> |
| + <h2 id="h-sim">Run the waterfall</h2> |
| + <p class="note explain">Pick a scenario; the page runs the published |
| + algorithm over it, quarter by quarter. An illustration, not a forecast — |
| + and not an offer.</p> |
| + <div class="controls"> |
| + <div class="ctl"> |
| + <label for="c-raise">Series raise</label> |
| + <output id="o-raise" for="c-raise">—</output> |
| + <input type="range" id="c-raise" min="50000" max="1000000" step="25000" value="300000" /> |
| + </div> |
| + <div class="ctl"> |
| + <label for="c-cap">Cap multiple</label> |
| + <output id="o-cap" for="c-cap">—</output> |
| + <input type="range" id="c-cap" min="1.5" max="3" step="0.1" value="2" /> |
| + </div> |
| + <div class="ctl"> |
| + <label for="c-rev">Quarterly revenue (starting)</label> |
| + <output id="o-rev" for="c-rev">—</output> |
| + <input type="range" id="c-rev" min="0" max="150000" step="2500" value="40000" /> |
| + </div> |
| + <div class="ctl"> |
| + <label for="c-growth">Revenue growth per quarter</label> |
| + <output id="o-growth" for="c-growth">—</output> |
| + <input type="range" id="c-growth" min="0" max="15" step="0.5" value="4" /> |
| + </div> |
| + <div class="ctl"> |
| + <label for="c-costs">Quarterly operating costs</label> |
| + <output id="o-costs" for="c-costs">—</output> |
| + <input type="range" id="c-costs" min="5000" max="150000" step="1000" value="28000" /> |
| + </div> |
| + </div> |
| + |
| + <div class="tiles"> |
| + <div class="tile"><div class="label">First distribution</div><div class="value" id="t-first" style="font-size:22px">—</div><div class="sub" id="t-first-sub"></div></div> |
| + <div class="tile"><div class="label">Noteholders fully repaid</div><div class="value" id="t-repaid" style="font-size:22px">—</div><div class="sub" id="t-repaid-sub"></div></div> |
| + <div class="tile"><div class="label">To the commons by then</div><div class="value" id="t-commons" style="font-size:22px">—</div><div class="sub" id="t-commons-sub"></div></div> |
| + </div> |
| + |
| + <ul class="legend" id="legend"> |
| + <li><span class="chip" style="background:var(--pool-investors)"></span>Noteholders (cumulative)</li> |
| + <li><span class="chip" style="background:var(--pool-team)"></span>Team (cumulative)</li> |
| + <li><span class="chip" style="background:var(--pool-commons)"></span>Commons (cumulative)</li> |
| + <li><span class="chip dash"></span>Remaining cap</li> |
| + </ul> |
| + <figure aria-labelledby="h-sim"> |
| + <svg id="chart" viewBox="0 0 720 260" role="img" aria-label="" tabindex="0"></svg> |
| + <div id="tip"></div> |
| + <figcaption id="chart-cap">Cumulative euros streamed to each pool over |
| + the scenario, against the falling remaining cap. Hover, or focus the |
| + chart and use the arrow keys, to read any quarter; the table view below |
| + carries the same numbers.<span id="chart-window-note"></span></figcaption> |
| + </figure> |
| + <details class="tableview"> |
| + <summary>Table view (yearly)</summary> |
| + <div class="tablewrap"> |
| + <table id="sim-table"> |
| + <caption>Simulated yearly totals for the chosen scenario</caption> |
| + <thead><tr> |
| + <th scope="col">Year end</th> |
| + <th scope="col" class="num">Revenue / quarter</th> |
| + <th scope="col" class="num">Noteholders (cum.)</th> |
| + <th scope="col" class="num">Team (cum.)</th> |
| + <th scope="col" class="num">Commons (cum.)</th> |
| + <th scope="col" class="num">Remaining cap</th> |
| + </tr></thead> |
| + <tbody></tbody> |
| + </table> |
| + </div> |
| + </details> |
| + <p class="note" id="sim-note"></p> |
| + </section> |
| + |
| + <section aria-labelledby="h-series"> |
| + <h2 id="h-series">Series</h2> |
| + <div id="series-out"></div> |
| + </section> |
| + |
| + <section aria-labelledby="h-notes"> |
| + <h2 id="h-notes">Notes</h2> |
| + <p class="note explain">Pseudonymous by design: amounts and movements are |
| + public; identities never are. A note id buys no influence — there is |
| + nothing to buy.</p> |
| + <div id="notes-out"></div> |
| + </section> |
| + |
| + <section aria-labelledby="h-quarters"> |
| + <h2 id="h-quarters">Quarterly distributions</h2> |
| + <div id="quarters-out"></div> |
| + </section> |
| + |
| + <footer> |
| + <p><strong>Check this page yourself.</strong> The algorithm is |
| + <a href="./waterfall.js">waterfall.js</a> (served here, imported by this |
| + page — the simulator runs it, not a copy); the ledger is |
| + <a href="./funding.json">funding.json</a>. All ledger amounts are integer |
| + cents, and every computation is deterministic — re-running the algorithm |
| + over the ledger must reproduce every published payment. An audit is a |
| + re-execution.</p> |
| + </footer> |
| +</div> |
| + |
| +<script type="module"> |
| +import { applyQuarter, SPLIT, DEFAULT_SEASONING_MONTHS, RESERVE_MONTHS, |
| + CAP_RANGE, DEFAULT_CAP_MULTIPLE } from "./waterfall.js"; |
| + |
| +const $ = (id) => document.getElementById(id); |
| +const NS = "http://www.w3.org/2000/svg"; |
| +const eur = new Intl.NumberFormat("en-IE", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }); |
| +const eurCents = (c) => eur.format(Math.round(c / 100)); |
| +const plur = (n, w) => n.toLocaleString() + " " + w + (n === 1 ? "" : "s"); |
| + |
| +// ---- Parameters (from the algorithm module — one source, no copy) ---------- |
| +{ |
| + const tiles = $("param-tiles"); |
| + const tile = (label, value, sub) => { |
| + const d = document.createElement("div"); |
| + d.className = "tile"; |
| + const l = document.createElement("div"); l.className = "label"; l.textContent = label; |
| + const v = document.createElement("div"); v.className = "value"; v.style.fontSize = "22px"; v.textContent = value; |
| + d.append(l, v); |
| + if (sub) { const s = document.createElement("div"); s.className = "sub"; s.textContent = sub; d.append(s); } |
| + tiles.append(d); |
| + }; |
| + tile("Noteholders", SPLIT.investors + "%", "of distributable surplus, pro-rata by remaining cap"); |
| + tile("Team", SPLIT.team + "%", "the profit-share pool"); |
| + tile("Commons", SPLIT.commons + "%", "price cuts, free-tier subsidy, grants — plus every rounding cent"); |
| + tile("Seasoning", plur(DEFAULT_SEASONING_MONTHS, "month"), "before a series can receive anything"); |
| + tile("Reserve floor", plur(RESERVE_MONTHS, "month"), "of forward costs held before anything is distributable"); |
| + tile("Cap multiple", CAP_RANGE[0].toLocaleString() + "–" + CAP_RANGE[1].toLocaleString() + "×", "fixed per series; the whole return, then the note ends"); |
| +} |
| + |
| +// ---- Scenario simulator ----------------------------------------------------- |
| +// The waterfall is the imported published algorithm; only the scenario AROUND |
| +// it is modelled here (one series, flat costs, compounding revenue). Kept |
| +// deliberately simple: the point is to show how the algorithm behaves, not to |
| +// predict anything. |
| +const HORIZON_QUARTERS = 60; // 15 years |
| +const QENDS = ["03-31", "06-30", "09-30", "12-31"]; |
| + |
| +function simulate(p) { |
| + const cents = (e) => Math.round(e * 100); |
| + let ledger = { |
| + series: [{ id: "S1", closedAt: "2027-01-01", capMultiple: p.capMultiple, seasoningMonths: DEFAULT_SEASONING_MONTHS }], |
| + notes: [{ id: "S1-001", series: "S1", principal: cents(p.raise), remainingCap: Math.round(cents(p.raise) * p.capMultiple) }], |
| + quarters: [], |
| + }; |
| + // The raise sits in the treasury; the floor is RESERVE_MONTHS of the cost |
| + // base. Retained pools and residue stay in the treasury (their destination |
| + // is a published decision, so the scenario just holds them). |
| + let treasury = cents(p.raise); |
| + const floor = Math.round(cents(p.costs) * (RESERVE_MONTHS / 3)); |
| + const rows = []; |
| + let cumI = 0, cumT = 0, cumC = 0, brokeAt = -1; |
| + for (let q = 0; q < HORIZON_QUARTERS; q++) { |
| + const year = 2027 + Math.floor(q / 4), qi = q % 4; |
| + const revenue = Math.round(cents(p.revenue0) * Math.pow(1 + p.growthPct / 100, q)); |
| + const costs = cents(p.costs); |
| + const reserveTopUp = Math.max(0, Math.min(Math.max(0, revenue - costs), floor - treasury)); |
| + const step = applyQuarter(ledger, { |
| + id: year + "-Q" + (qi + 1), asOf: year + "-" + QENDS[qi], |
| + revenue, costs, reserveTopUp, floorMet: false, // patched below once cash is known |
| + }); |
| + const r = step.result; |
| + const paid = r.payments.reduce((s, x) => s + x.amount, 0); |
| + const out = r.distributed ? paid + r.teamPool + r.commonsPool - r.retained : 0; |
| + treasury += revenue - costs - out; |
| + // floorMet is a fact about the treasury at quarter end; the algorithm only |
| + // ever reads it from PRIOR quarters, so patching the fresh record is sound. |
| + step.ledger.quarters[step.ledger.quarters.length - 1].floorMet = treasury >= floor; |
| + ledger = step.ledger; |
| + if (treasury < 0 && brokeAt < 0) brokeAt = q; |
| + cumI += paid; cumT += r.distributed ? r.teamPool : 0; cumC += r.distributed ? r.commonsPool : 0; |
| + rows.push({ |
| + q, label: year + " Q" + (qi + 1), yearEnd: qi === 3 ? year : 0, |
| + revenue, paid, cumI, cumT, cumC, |
| + remainingCap: ledger.notes[0].remainingCap, treasury, |
| + }); |
| + } |
| + const capTotal = Math.round(cents(p.raise) * p.capMultiple); |
| + return { |
| + rows, capTotal, brokeAt, |
| + firstAt: rows.findIndex((r) => r.paid > 0), |
| + repaidAt: rows.findIndex((r) => r.remainingCap === 0), |
| + }; |
| +} |
| + |
| +// ---- Controls --------------------------------------------------------------- |
| +const CTLS = [ |
| + { id: "c-raise", out: "o-raise", key: "raise", fmt: (v) => eur.format(v) }, |
| + { id: "c-cap", out: "o-cap", key: "capMultiple", fmt: (v) => v.toLocaleString(undefined, { minimumFractionDigits: 1 }) + "×" }, |
| + { id: "c-rev", out: "o-rev", key: "revenue0", fmt: (v) => eur.format(v) }, |
| + { id: "c-growth", out: "o-growth", key: "growthPct", fmt: (v) => v.toLocaleString() + "% / quarter" }, |
| + { id: "c-costs", out: "o-costs", key: "costs", fmt: (v) => eur.format(v) }, |
| +]; |
| +$("c-cap").min = CAP_RANGE[0]; $("c-cap").max = CAP_RANGE[1]; $("c-cap").value = DEFAULT_CAP_MULTIPLE; |
| + |
| +function params() { |
| + const p = {}; |
| + for (const c of CTLS) { |
| + const v = Number($(c.id).value); |
| + p[c.key] = v; |
| + $(c.out).textContent = c.fmt(v); |
| + } |
| + return p; |
| +} |
| + |
| +// ---- Chart ------------------------------------------------------------------ |
| +// Four marks, one euro axis: three cumulative pool lines (categorical hues) |
| +// and the falling remaining-cap bound as a dashed neutral line. Crosshair |
| +// snaps to the nearest quarter; ←/→ walk quarters when the chart is focused. |
| +// No animation, so there is nothing to reduce for prefers-reduced-motion. |
| +let sim = null, tipIdx = -1; |
| + |
| +function yearsWord(q) { |
| + const yrs = (q + 1) / 4; |
| + const r = Math.round(yrs * 2) / 2; |
| + return r === 1 ? "1 year" : r.toLocaleString() + " years"; |
| +} |
| + |
| +function drawChart() { |
| + const svg = $("chart"); |
| + svg.replaceChildren(); |
| + const { capTotal } = sim; |
| + // Window to the payback story: through repayment plus two years when the |
| + // cap fills, the whole horizon otherwise. The table view always carries the |
| + // full horizon, so nothing is hidden — just focused. |
| + const lastQ = sim.repaidAt >= 0 ? Math.min(sim.rows.length - 1, sim.repaidAt + 8) : sim.rows.length - 1; |
| + const rows = sim.rows.slice(0, lastQ + 1); |
| + $("chart-window-note").textContent = lastQ < sim.rows.length - 1 |
| + ? " Chart shown through repayment plus two years; the table runs the full horizon." |
| + : ""; |
| + const W = 720, H = 260, m = { l: 56, r: 118, t: 14, b: 26 }; |
| + const x = (q) => m.l + (W - m.l - m.r) * (q / (rows.length - 1)); |
| + // Every plotted value must fit: the cap bound AND all three streams — team |
| + // and commons keep accruing after the cap fills, so they can top everything. |
| + const top = Math.max(1, ...rows.map((r) => Math.max(r.remainingCap, r.cumI, r.cumT, r.cumC))); |
| + let step = 1; |
| + for (const s of [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000]) { |
| + if (top / 100000 / s <= 4) { step = s; break; } // ticks in €k, ≤5 lines |
| + } |
| + const yMax = Math.max(step, Math.ceil(top / 100000 / step) * step) * 100000; |
| + const y = (c) => H - m.b - (H - m.b - m.t) * (c / yMax); |
| + const el = (name, attrs, text) => { |
| + const n = document.createElementNS(NS, name); |
| + for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v); |
| + if (text !== undefined) n.textContent = text; |
| + svg.append(n); |
| + return n; |
| + }; |
| + |
| + for (let v = 0; v <= yMax; v += step * 100000) { |
| + el("line", { x1: m.l, x2: W - m.r, y1: y(v), y2: y(v), stroke: "var(--grid)", "stroke-width": 1 }); |
| + el("text", { x: m.l - 8, y: y(v) + 4, "text-anchor": "end", "font-size": 12, |
| + fill: "var(--ink-2)", style: "font-variant-numeric: tabular-nums" }, v === 0 ? "€0" : "€" + (v / 100000) + "k"); |
| + } |
| + const yearEnds = rows.filter((r) => r.yearEnd); |
| + const yStep = Math.max(1, Math.ceil(yearEnds.length / 6)); |
| + yearEnds.forEach((r, i) => { |
| + if (i % yStep === yStep - 1) { |
| + el("text", { x: x(r.q), y: H - 8, "text-anchor": "middle", "font-size": 12, fill: "var(--ink-2)" }, String(r.yearEnd)); |
| + } |
| + }); |
| + |
| + const path = (pick) => rows.map((r, i) => (i ? "L" : "M") + x(r.q).toFixed(1) + " " + y(pick(r)).toFixed(1)).join(" "); |
| + el("path", { d: "M" + x(0) + " " + y(capTotal) + " " + path((r) => r.remainingCap).slice(1), |
| + fill: "none", stroke: "var(--ink-2)", "stroke-width": 2, "stroke-dasharray": "6 5", "stroke-linejoin": "round" }); |
| + const seriesDefs = [ |
| + { pick: (r) => r.cumI, color: "var(--pool-investors)", name: "noteholders" }, |
| + { pick: (r) => r.cumT, color: "var(--pool-team)", name: "team" }, |
| + { pick: (r) => r.cumC, color: "var(--pool-commons)", name: "commons" }, |
| + ]; |
| + for (const s of seriesDefs) { |
| + el("path", { d: path(s.pick), fill: "none", stroke: s.color, |
| + "stroke-width": 2, "stroke-linejoin": "round", "stroke-linecap": "round" }); |
| + } |
| + |
| + // Right-edge direct labels (the relief rule for the low-contrast hues — |
| + // identity is never color alone). Nudge apart when lines end close together. |
| + // Identity-only direct labels (the relief rule): the values live in the |
| + // tooltip, the tiles, and the table — a name fits the margin, a sum doesn't. |
| + const end = rows[rows.length - 1]; |
| + const band = [m.t + 8, H - m.b - 4]; |
| + const labels = [ |
| + { y: y(end.remainingCap), text: "remaining cap", fill: "var(--ink-2)", w: 400 }, |
| + { y: y(end.cumI), text: "noteholders", fill: "var(--ink)", w: 650 }, |
| + { y: y(end.cumT), text: "team", fill: "var(--ink-2)", w: 400 }, |
| + { y: y(end.cumC), text: "commons", fill: "var(--ink-2)", w: 400 }, |
| + ].map((L) => ({ ...L, y: Math.min(band[1], Math.max(band[0], L.y)) })) |
| + .sort((a, b) => a.y - b.y); |
| + for (let i = 1; i < labels.length; i++) { |
| + if (labels[i].y - labels[i - 1].y < 14) labels[i].y = labels[i - 1].y + 14; |
| + } |
| + const over = labels[labels.length - 1].y - band[1]; |
| + if (over > 0) for (const L of labels) L.y -= over; // slide the stack back into the band |
| + for (const L of labels) { |
| + el("text", { x: W - m.r + 8, y: L.y + 4, "font-size": 12, "font-weight": L.w, fill: L.fill }, L.text); |
| + } |
| + |
| + const horizon = yearsWord(rows.length - 1); |
| + const repaid = sim.repaidAt >= 0 |
| + ? "the cap fills in " + yearsWord(sim.repaidAt) |
| + : "the cap does not fill within " + horizon; |
| + svg.setAttribute("aria-label", "Simulated streams over " + horizon + ": " + |
| + eurCents(end.cumI) + " to noteholders, " + eurCents(end.cumT) + " to the team, " + |
| + eurCents(end.cumC) + " to the commons; " + repaid + "."); |
| + |
| + // Crosshair + tooltip: the whole plot is the hit area, snapping to the |
| + // nearest quarter; arrow keys walk quarters when the svg has focus. |
| + const hair = el("line", { y1: m.t, y2: H - m.b, stroke: "var(--grid)", "stroke-width": 1, visibility: "hidden" }); |
| + const tip = $("tip"); |
| + const show = (i) => { |
| + tipIdx = i; |
| + const r = rows[i]; |
| + hair.setAttribute("x1", x(r.q)); hair.setAttribute("x2", x(r.q)); |
| + hair.setAttribute("visibility", "visible"); |
| + tip.replaceChildren(); |
| + const big = document.createElement("div"); |
| + big.className = "big"; |
| + big.textContent = r.label; |
| + tip.append(big); |
| + for (const [label, v] of [["Noteholders", r.cumI], ["Team", r.cumT], ["Commons", r.cumC], ["Remaining cap", r.remainingCap]]) { |
| + const d = document.createElement("div"); |
| + d.className = "sub"; |
| + d.textContent = label + ": " + eurCents(v); |
| + tip.append(d); |
| + } |
| + tip.style.display = "block"; |
| + const box = svg.getBoundingClientRect(); |
| + const px = (x(r.q) / W) * box.width; |
| + tip.style.left = Math.min(Math.max(0, px - 70), box.width - tip.offsetWidth - 4) + "px"; |
| + tip.style.top = Math.max(0, (y(r.cumI) / H) * box.height - tip.offsetHeight - 14) + "px"; |
| + }; |
| + const hide = () => { tipIdx = -1; hair.setAttribute("visibility", "hidden"); tip.style.display = "none"; }; |
| + svg.addEventListener("pointermove", (ev) => { |
| + const box = svg.getBoundingClientRect(); |
| + const mx = ((ev.clientX - box.left) / box.width) * W; |
| + let best = 0; |
| + for (const r of rows) if (Math.abs(x(r.q) - mx) < Math.abs(x(best) - mx)) best = r.q; |
| + show(best); |
| + }); |
| + svg.addEventListener("pointerleave", hide); |
| + svg.addEventListener("keydown", (ev) => { |
| + if (ev.key === "ArrowRight" || ev.key === "ArrowLeft") { |
| + ev.preventDefault(); |
| + const next = tipIdx < 0 ? (ev.key === "ArrowRight" ? 0 : rows.length - 1) |
| + : Math.min(rows.length - 1, Math.max(0, tipIdx + (ev.key === "ArrowRight" ? 1 : -1))); |
| + show(next); |
| + } else if (ev.key === "Escape") hide(); |
| + }); |
| + svg.addEventListener("blur", hide); |
| +} |
| + |
| +function renderSim() { |
| + sim = simulate(params()); |
| + const horizon = yearsWord(HORIZON_QUARTERS - 1); |
| + |
| + const firstTile = $("t-first"), firstSub = $("t-first-sub"); |
| + if (sim.firstAt >= 0) { |
| + firstTile.textContent = sim.rows[sim.firstAt].label; |
| + firstSub.textContent = "after " + yearsWord(sim.firstAt) + " — seasoning plus the reserve floor"; |
| + } else { |
| + firstTile.textContent = "Not within " + horizon; |
| + firstSub.textContent = "this scenario never clears seasoning and the reserve floor"; |
| + } |
| + const repaidTile = $("t-repaid"), repaidSub = $("t-repaid-sub"); |
| + if (sim.repaidAt >= 0) { |
| + repaidTile.textContent = sim.rows[sim.repaidAt].label; |
| + repaidSub.textContent = eurCents(sim.capTotal) + " streamed — every note extinguished after " + yearsWord(sim.repaidAt); |
| + } else { |
| + const endCum = sim.rows[sim.rows.length - 1].cumI; |
| + repaidTile.textContent = "Not within " + horizon; |
| + repaidSub.textContent = eurCents(endCum) + " of " + eurCents(sim.capTotal) + " streamed by the horizon — patient money, by construction"; |
| + } |
| + const upTo = sim.repaidAt >= 0 ? sim.repaidAt : sim.rows.length - 1; |
| + $("t-commons").textContent = eurCents(sim.rows[upTo].cumC); |
| + $("t-commons-sub").textContent = "cumulative by " + sim.rows[upTo].label + " (team: " + eurCents(sim.rows[upTo].cumT) + ")"; |
| + |
| + $("sim-note").textContent = sim.brokeAt >= 0 |
| + ? "Honesty check: this scenario runs out of money in " + sim.rows[sim.brokeAt].label + |
| + " — costs outrun revenue and the raise. Nothing streams from a treasury that cannot cover operations." |
| + : ""; |
| + |
| + const tbody = $("sim-table").querySelector("tbody"); |
| + tbody.replaceChildren(); |
| + for (const r of sim.rows) { |
| + if (!r.yearEnd) continue; |
| + const tr = document.createElement("tr"); |
| + const th = document.createElement("th"); |
| + th.scope = "row"; |
| + th.textContent = String(r.yearEnd); |
| + tr.append(th); |
| + for (const v of [r.revenue, r.cumI, r.cumT, r.cumC, r.remainingCap]) { |
| + const td = document.createElement("td"); |
| + td.className = "num"; |
| + td.textContent = eurCents(v); |
| + tr.append(td); |
| + } |
| + tbody.append(tr); |
| + } |
| + |
| + drawChart(); |
| +} |
| + |
| +for (const c of CTLS) $(c.id).addEventListener("input", renderSim); |
| +renderSim(); |
| + |
| +// ---- The ledger (funding.json — committed data) ----------------------------- |
| +function emptyNote(target, text) { |
| + const d = document.createElement("p"); |
| + d.className = "empty"; |
| + d.textContent = text; |
| + target.replaceChildren(d); |
| +} |
| + |
| +function table(target, caption, heads, rows) { |
| + const wrap = document.createElement("div"); |
| + wrap.className = "tablewrap"; |
| + const t = document.createElement("table"); |
| + const cap = document.createElement("caption"); |
| + cap.textContent = caption; |
| + const thead = document.createElement("thead"); |
| + const hr = document.createElement("tr"); |
| + for (const h of heads) { |
| + const th = document.createElement("th"); |
| + th.scope = "col"; |
| + if (h.num) th.className = "num"; |
| + th.textContent = h.label; |
| + hr.append(th); |
| + } |
| + thead.append(hr); |
| + const tbody = document.createElement("tbody"); |
| + for (const row of rows) { |
| + const tr = document.createElement("tr"); |
| + row.forEach((cell, i) => { |
| + const td = document.createElement(i === 0 ? "th" : "td"); |
| + if (i === 0) td.scope = "row"; |
| + if (heads[i].num) td.className = "num"; |
| + td.textContent = cell; |
| + tr.append(td); |
| + }); |
| + tbody.append(tr); |
| + } |
| + t.append(cap, thead, tbody); |
| + wrap.append(t); |
| + target.replaceChildren(wrap); |
| +} |
| + |
| +function renderLedger(d) { |
| + $("asof").dateTime = d.as_of; |
| + $("asof").textContent = new Date(d.as_of + "T00:00:00Z") |
| + .toLocaleDateString(undefined, { dateStyle: "long" }); |
| + |
| + if (d.adopted) { |
| + const co = $("status-callout"); |
| + co.classList.add("adopted"); |
| + $("status-head").textContent = "Status: adopted"; |
| + $("status-body").textContent = "The model is live. Every series below names " + |
| + "the algorithm version it was sold under; every quarter’s distributions " + |
| + "must be that version’s outputs for the published inputs."; |
| + } |
| + |
| + if (!d.series.length) { |
| + emptyNote($("series-out"), "None — no series has opened. When one does, it appears here before any money moves: size, cap multiple, seasoning, and the pinned algorithm version."); |
| + } else { |
| + table($("series-out"), "Note series", [ |
| + { label: "Series" }, { label: "Opened" }, { label: "Closed" }, |
| + { label: "Raised", num: true }, { label: "Cap", num: true }, |
| + { label: "Seasoning" }, { label: "Algorithm" }, |
| + ], d.series.map((s) => [ |
| + s.id, s.openedAt || "—", s.closedAt || "—", |
| + s.raised != null ? eurCents(s.raised) : "—", |
| + (s.capMultiple ?? DEFAULT_CAP_MULTIPLE).toLocaleString(undefined, { minimumFractionDigits: 1 }) + "×", |
| + plur(s.seasoningMonths ?? DEFAULT_SEASONING_MONTHS, "month"), |
| + s.algorithm || "—", |
| + ])); |
| + } |
| + |
| + if (!d.notes.length) { |
| + emptyNote($("notes-out"), "None — no notes exist. Each will show its pseudonymous id, principal, and remaining cap; a note at zero has been fully repaid and no longer exists."); |
| + } else { |
| + table($("notes-out"), "Outstanding notes", [ |
| + { label: "Note" }, { label: "Series" }, |
| + { label: "Principal", num: true }, { label: "Remaining cap", num: true }, |
| + ], d.notes.map((n) => [n.id, n.series, eurCents(n.principal), eurCents(n.remainingCap)])); |
| + } |
| + |
| + if (!d.quarters.length) { |
| + emptyNote($("quarters-out"), "None — nothing has been computed because nothing has been raised. Every quarter will publish its inputs (revenue, costs, reserve top-up) and every output, so re-running the algorithm reproduces each payment."); |
| + } else { |
| + table($("quarters-out"), "Quarterly waterfall computations", [ |
| + { label: "Quarter" }, { label: "Revenue", num: true }, { label: "Costs", num: true }, |
| + { label: "Reserve", num: true }, { label: "Noteholders", num: true }, |
| + { label: "Team", num: true }, { label: "Commons", num: true }, |
| + ], d.quarters.map((q) => [ |
| + q.id, eurCents(q.revenue), eurCents(q.costs), eurCents(q.reserveTopUp), |
| + eurCents((q.payments || []).reduce((s, p) => s + p.amount, 0)), |
| + eurCents(q.teamPool || 0), eurCents(q.commonsPool || 0), |
| + ])); |
| + } |
| +} |
| + |
| +async function refresh() { |
| + try { |
| + const res = await fetch("./funding.json", { cache: "no-cache" }); |
| + if (!res.ok) throw new Error("HTTP " + res.status); |
| + renderLedger(await res.json()); |
| + $("error").classList.remove("show"); |
| + } catch { |
| + $("error").classList.add("show"); |
| + } |
| +} |
| +refresh(); |
| +setInterval(refresh, 60000); |
| +</script> |
| +</body> |
| +</html> |
diff --git a/web/status/funding.json b/web/status/funding.json| new file mode 100644 |
| index 0000000..038bd55 |
| --- /dev/null |
| +++ b/web/status/funding.json |
| @@ -0,0 +1,8 @@ |
| +{ |
| + "_readme": "The public funding ledger (docs/funding.md § The ledger) — COMMITTED data, republished as-is by deploy/status-publish.sh and rendered by funding.html. All amounts are integer cents. Note ids are pseudonymous; identities are never published. adopted stays false until the model passes legal + tax review and a series deliberately opens.", |
| + "as_of": "2026-07-17", |
| + "adopted": false, |
| + "series": [], |
| + "notes": [], |
| + "quarters": [] |
| +} |
diff --git a/web/status/hardening.html b/web/status/hardening.html| index 1b22c67..2d58625 100644 |
| --- a/web/status/hardening.html |
| +++ b/web/status/hardening.html |
| @@ -88,6 +88,7 @@ |
| <a href="./index.html">Fleet</a> |
| <a href="./books.html">Income & Expenditure</a> |
| <a href="./economics.html">Economics</a> |
| + <a href="./funding.html">Investing</a> |
| <a href="./security.html">Security</a> |
| <a href="./carbon.html">Carbon use</a> |
| <a href="./contributors.html">Contributors</a> |
diff --git a/web/status/index.html b/web/status/index.html| index 68d482c..5499f27 100644 |
| --- a/web/status/index.html |
| +++ b/web/status/index.html |
| @@ -64,6 +64,7 @@ |
| <span aria-current="page">Fleet</span> |
| <a href="./books.html">Income & Expenditure</a> |
| <a href="./economics.html">Economics</a> |
| + <a href="./funding.html">Investing</a> |
| <a href="./security.html">Security</a> |
| <a href="./carbon.html">Carbon use</a> |
| <a href="./contributors.html">Contributors</a> |
diff --git a/web/status/milestones.html b/web/status/milestones.html| index c3eef80..9a18a7f 100644 |
| --- a/web/status/milestones.html |
| +++ b/web/status/milestones.html |
| @@ -49,6 +49,7 @@ |
| <a href="./index.html">Fleet</a> |
| <a href="./books.html">Income & Expenditure</a> |
| <a href="./economics.html">Economics</a> |
| + <a href="./funding.html">Investing</a> |
| <a href="./security.html">Security</a> |
| <a href="./carbon.html">Carbon use</a> |
| <a href="./contributors.html">Contributors</a> |
diff --git a/web/status/prompts.html b/web/status/prompts.html| index 4a60ca1..66be9dd 100644 |
| --- a/web/status/prompts.html |
| +++ b/web/status/prompts.html |
| @@ -112,6 +112,7 @@ |
| <a href="./index.html">Fleet</a> |
| <a href="./books.html">Income & Expenditure</a> |
| <a href="./economics.html">Economics</a> |
| + <a href="./funding.html">Investing</a> |
| <a href="./security.html">Security</a> |
| <a href="./carbon.html">Carbon use</a> |
| <a href="./contributors.html">Contributors</a> |
diff --git a/web/status/security.html b/web/status/security.html| index 4ec1bb2..795c193 100644 |
| --- a/web/status/security.html |
| +++ b/web/status/security.html |
| @@ -66,6 +66,7 @@ |
| <a href="./index.html">Fleet</a> |
| <a href="./books.html">Income & Expenditure</a> |
| <a href="./economics.html">Economics</a> |
| + <a href="./funding.html">Investing</a> |
| <span aria-current="page">Security</span> |
| <a href="./carbon.html">Carbon use</a> |
| <a href="./contributors.html">Contributors</a> |
diff --git a/web/status/ssh.html b/web/status/ssh.html| index 310aba8..7bf38bd 100644 |
| --- a/web/status/ssh.html |
| +++ b/web/status/ssh.html |
| @@ -67,6 +67,7 @@ |
| <a href="./index.html">Fleet</a> |
| <a href="./books.html">Income & Expenditure</a> |
| <a href="./economics.html">Economics</a> |
| + <a href="./funding.html">Investing</a> |
| <a href="./security.html">Security</a> |
| <a href="./carbon.html">Carbon use</a> |
| <a href="./contributors.html">Contributors</a> |
diff --git a/web/status/status.css b/web/status/status.css| index ca3e643..9385901 100644 |
| --- a/web/status/status.css |
| +++ b/web/status/status.css |
| @@ -1,8 +1,9 @@ |
| /* 🤖 Shared stylesheet for the public status pages (web/status/*.html): fleet, |
| - income & expenditure, tier economics, security, carbon, contributors, the |
| - prompt ledger, the SSH access ledger, and the fleet hardening posture. |
| + income & expenditure, tier economics, investing, security, carbon, |
| + contributors, the prompt ledger, the SSH access ledger, and the fleet |
| + hardening posture. |
| ONE design system — the design tokens, the reset, and every component the |
| - pages share (the brand masthead, the nine-item nav, tiles, the single-series |
| + pages share (the brand masthead, the ten-item nav, tiles, the single-series |
| bar list, notes, the "couldn't load" alert, footer). Each page <link>s this |
| and keeps only a SMALL inline <style> for its own unique bits. |
diff --git a/web/status/waterfall.js b/web/status/waterfall.js| new file mode 100644 |
| index 0000000..a5ff1ff |
| --- /dev/null |
| +++ b/web/status/waterfall.js |
| @@ -0,0 +1,166 @@ |
| +// web/status/waterfall.js — the funding waterfall's reference implementation. |
| +// |
| +// Normative spec: docs/funding.md (§ The waterfall). A note legally pins that |
| +// prose at a git commit; this module is the runnable form of the same |
| +// computation, so anyone can re-execute a published quarter straight from |
| +// web/status/funding.json — an audit is a re-execution. It lives HERE, in the |
| +// published status dir, so the public Investing page (funding.html) imports |
| +// and runs the very file it serves — the simulator on that page IS this |
| +// algorithm, not a copy of it. Node-side consumers import the re-export shim |
| +// tools/waterfall.mjs. Pure functions only: no clock, no filesystem, no |
| +// network, no DOM. All amounts are integer cents, because floats have no |
| +// business near money. |
| + |
| +// The algorithm's parameters. Changing any of these is a new algorithm |
| +// version (docs/funding.md § Changing the algorithm), never a code tweak. |
| +export const SPLIT = { investors: 60, team: 25, commons: 15 }; // % of D |
| +export const DEFAULT_SEASONING_MONTHS = 24; |
| +export const RESERVE_MONTHS = 12; // the floor behind reserveTopUp (computed upstream) |
| +export const CAP_RANGE = [1.5, 3]; // a series fixes its cap multiple in here |
| +export const DEFAULT_CAP_MULTIPLE = 2; |
| + |
| +// Whole calendar months between two ISO dates (UTC). A part-month doesn't |
| +// count: 2026-09-30 → 2028-09-29 is 23 months, → 2028-09-30 is 24. |
| +export function monthsBetween(fromISO, toISO) { |
| + const a = new Date(fromISO + "T00:00:00Z"); |
| + const b = new Date(toISO + "T00:00:00Z"); |
| + let m = (b.getUTCFullYear() - a.getUTCFullYear()) * 12 + (b.getUTCMonth() - a.getUTCMonth()); |
| + if (b.getUTCDate() < a.getUTCDate()) m--; |
| + return m; |
| +} |
| + |
| +// A series is seasoned when BOTH legs of the spec's "later of" hold: the |
| +// calendar has passed, and the reserve floor held for the two most recent |
| +// published quarters. Fewer than two published quarters can never season — |
| +// a brand-new company distributes to no one. |
| +export function seasoned(series, asOf, priorQuarters) { |
| + const months = series.seasoningMonths ?? DEFAULT_SEASONING_MONTHS; |
| + if (!series.closedAt || monthsBetween(series.closedAt, asOf) < months) return false; |
| + const recent = priorQuarters.slice(-2); |
| + return recent.length === 2 && recent.every((q) => q.floorMet === true); |
| +} |
| + |
| +// D = R − C − F. May be ≤ 0 — the caller treats that as "distribute nothing". |
| +export function distributable({ revenue, costs, reserveTopUp }) { |
| + return revenue - costs - reserveTopUp; |
| +} |
| + |
| +// Split D into the three pools. The investor and team pools round down, so |
| +// the leftover cents always land with the commons — rounding never favours |
| +// the money. |
| +export function splitSurplus(D) { |
| + const investors = Math.floor((D * SPLIT.investors) / 100); |
| + const team = Math.floor((D * SPLIT.team) / 100); |
| + return { investors, team, commons: D - investors - team }; |
| +} |
| + |
| +// Pro-rata by remaining cap, deterministic to the cent: floor allocations |
| +// first, then the leftover cents go one at a time by largest fractional |
| +// remainder, ties broken by note id — two independent runs can never |
| +// disagree. No note is ever paid past its cap; if the pool covers every cap, |
| +// the excess is `residue`, whose destination is a published decision |
| +// (docs/funding.md), not this algorithm's. |
| +export function allocate(pool, notes) { |
| + const eligible = notes |
| + .filter((n) => n.remainingCap > 0) |
| + .slice() |
| + .sort((a, b) => (a.id < b.id ? -1 : 1)); |
| + if (pool <= 0 || eligible.length === 0) return { payments: [], residue: 0 }; |
| + const totalCap = eligible.reduce((s, n) => s + n.remainingCap, 0); |
| + if (pool >= totalCap) { |
| + return { |
| + payments: eligible.map((n) => ({ note: n.id, amount: n.remainingCap })), |
| + residue: pool - totalCap, |
| + }; |
| + } |
| + const shares = eligible.map((n) => { |
| + const exact = (pool * n.remainingCap) / totalCap; |
| + return { note: n.id, cap: n.remainingCap, amount: Math.floor(exact), frac: exact - Math.floor(exact) }; |
| + }); |
| + // After flooring, every note sits strictly below its cap and the leftover |
| + // cents number fewer than the notes, so one pass always places them all. |
| + let left = pool - shares.reduce((s, x) => s + x.amount, 0); |
| + const byRemainder = shares.slice().sort((a, b) => b.frac - a.frac || (a.note < b.note ? -1 : 1)); |
| + for (const s of byRemainder) { |
| + if (left === 0) break; |
| + if (s.amount < s.cap) { s.amount++; left--; } |
| + } |
| + return { payments: shares.filter((s) => s.amount > 0).map(({ note, amount }) => ({ note, amount })), residue: 0 }; |
| +} |
| + |
| +// One quarter, end to end — docs/funding.md § The waterfall, runnable. |
| +// `ledger` is the published funding.json; `quarter` is this quarter's inputs. |
| +export function waterfall(ledger, quarter) { |
| + const D = distributable(quarter); |
| + const base = { |
| + quarter: quarter.id, |
| + inputs: { revenue: quarter.revenue, costs: quarter.costs, reserveTopUp: quarter.reserveTopUp }, |
| + distributable: D, |
| + distributed: false, |
| + investorPool: 0, |
| + teamPool: 0, |
| + commonsPool: 0, |
| + payments: [], |
| + retained: 0, |
| + residue: 0, |
| + extinguished: [], |
| + notes: ledger.notes.map((n) => ({ id: n.id, remainingCap: n.remainingCap })), |
| + }; |
| + if (D <= 0) return base; |
| + |
| + const { investors, team, commons } = splitSurplus(D); |
| + const seasonedIds = new Set( |
| + ledger.series.filter((s) => seasoned(s, quarter.asOf, ledger.quarters ?? [])).map((s) => s.id), |
| + ); |
| + // Seasoning gates noteholder payments only — team and commons still flow. |
| + const { payments, residue } = allocate(investors, ledger.notes.filter((n) => seasonedIds.has(n.series))); |
| + const paid = new Map(payments.map((p) => [p.note, p.amount])); |
| + const after = ledger.notes.map((n) => ({ id: n.id, remainingCap: n.remainingCap - (paid.get(n.id) ?? 0) })); |
| + return { |
| + ...base, |
| + distributed: true, |
| + investorPool: investors, |
| + teamPool: team, |
| + commonsPool: commons, |
| + payments, |
| + residue, |
| + // With no seasoned note to pay, the investor share sits in the treasury — |
| + // it is never redirected to the other pools. |
| + retained: payments.length === 0 ? investors : 0, |
| + extinguished: after.filter((n) => n.remainingCap === 0 && (paid.get(n.id) ?? 0) > 0).map((n) => n.id), |
| + notes: after, |
| + }; |
| +} |
| + |
| +// Fold a computed quarter back into the ledger. The published funding.json |
| +// for the next quarter is exactly this output — publishing and computing are |
| +// the same data structure, which is what makes re-execution an audit. |
| +export function applyQuarter(ledger, quarter) { |
| + const result = waterfall(ledger, quarter); |
| + const caps = new Map(result.notes.map((n) => [n.id, n.remainingCap])); |
| + return { |
| + result, |
| + ledger: { |
| + ...ledger, |
| + notes: ledger.notes.map((n) => ({ ...n, remainingCap: caps.get(n.id) })), |
| + quarters: [ |
| + ...(ledger.quarters ?? []), |
| + { |
| + id: quarter.id, |
| + asOf: quarter.asOf, |
| + revenue: quarter.revenue, |
| + costs: quarter.costs, |
| + reserveTopUp: quarter.reserveTopUp, |
| + floorMet: quarter.floorMet === true, |
| + distributed: result.distributed, |
| + investorPool: result.investorPool, |
| + teamPool: result.teamPool, |
| + commonsPool: result.commonsPool, |
| + payments: result.payments, |
| + retained: result.retained, |
| + residue: result.residue, |
| + }, |
| + ], |
| + }, |
| + }; |
| +} |