eleven / messenger Public

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>
Paul Campbell 56e5d2c064a2a0c717578375f406209621d10599 parent 265d7db
21 files changed, +1570 −5
  • deploy/status-publish.sh +8 −0
  • docs/funding.md +277 −0
  • docs/status.md +12 −1
  • package.json +1 −1
  • test/waterfall-vectors.json +269 −0
  • test/waterfall.test.mjs +132 −0
  • tools/waterfall.mjs +6 −0
  • web/status/books.html +1 −0
  • web/status/carbon.html +1 −0
  • web/status/contributors.html +1 −0
  • web/status/economics.html +1 −0
  • web/status/funding.html +677 −0
  • web/status/funding.json +8 −0
  • web/status/hardening.html +1 −0
  • web/status/index.html +1 −0
  • web/status/milestones.html +1 −0
  • web/status/prompts.html +1 −0
  • web/status/security.html +1 −0
  • web/status/ssh.html +1 −0
  • web/status/status.css +4 −3
  • web/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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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,
+ },
+ ],
+ },
+ };
+}