Skip to main content

max / makenotwork

docengine: vendor assumptions.toml fixture, gate substitute_dir bin Copy server/docs/business/assumptions.toml into tests/fixtures/ and point the assumptions test FIXTURE at the in-crate copy instead of reaching up into the server repo, so docengine builds and tests in isolation. Add an explicit [[bin]] entry for substitute_dir with required-features = ["assumptions"]; it uses docengine::Assumptions unconditionally, so a default-feature build previously failed to compile. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-17 15:44 UTC
Commit: 8b58be5c9b09b4aae889f4f2ffeef12f91a8a8b8
Parent: 648ef70
3 files changed, +432 insertions, -7 deletions
@@ -27,3 +27,7 @@ tracing = { version = "0.1", optional = true }
27 27
28 28 [dev-dependencies]
29 29 tempfile = "3"
30 +
31 + [[bin]]
32 + name = "substitute_dir"
33 + required-features = ["assumptions"]
@@ -668,13 +668,12 @@ fn insert_derived(t: &Typed, out: &mut HashMap<String, LookupValue>) {
668 668 mod tests {
669 669 use super::*;
670 670
671 - // Canonical assumptions.toml ships with the MNW repo at
672 - // server/docs/business/assumptions.toml. include_str! resolves relative to
673 - // this source file (MNW/shared/docengine/src/), so we walk up to MNW/ then
674 - // into server/docs/business/.
675 - const FIXTURE: &str = include_str!(
676 - "../../../server/docs/business/assumptions.toml"
677 - );
671 + // Vendored copy of the canonical assumptions.toml (the source of truth
672 + // lives in the MNW server repo at server/docs/business/assumptions.toml).
673 + // Kept in-crate so docengine builds and tests in isolation, without
674 + // reaching across repos. Refresh with:
675 + // cp ../../server/docs/business/assumptions.toml tests/fixtures/
676 + const FIXTURE: &str = include_str!("../tests/fixtures/assumptions.toml");
678 677
679 678 fn loaded() -> Assumptions {
680 679 Assumptions::parse(FIXTURE).expect("fixture parses")
@@ -0,0 +1,422 @@
1 + # MNW Business Assumptions — Machine-Readable Source of Truth
2 + #
3 + # Status: draft / not yet consumed by tooling. Target consumer is a future
4 + # docengine feature that substitutes `{{ key }}` placeholders in markdown
5 + # at build time. See `MNW/server/docs/todo.md` § "Docengine — assumption
6 + # substitution" for the proposed implementation.
7 + #
8 + # Until that feature ships, this file is informational. The canonical
9 + # values still live in the markdown docs listed in each section header.
10 + #
11 + # Conventions:
12 + # - All currency in USD unless suffixed `_eur`.
13 + # - All time in months unless suffixed `_days` / `_years`.
14 + # - Percentages as decimals (0.029 not 2.9).
15 + # - Each section names the canonical markdown source.
16 + # - Open decisions tagged `# OPEN:` — these are placeholder values
17 + # pending founder approval per `pricing.md` §7 and `reserve_policy.md` §8.
18 + #
19 + # As of: 2026-05-16
20 + # Last manual-verified: 2026-05-16
21 +
22 +
23 + # ─── Expenses (canonical: expenses.md) ────────────────────────────────────
24 + [expenses]
25 + F_monthly = 580 # Total fixed monthly burn (rounded from $579.08)
26 + F_monthly_exact = 579.08
27 + as_of = "2026-05-16"
28 +
29 + [expenses.lines]
30 + hetzner = 31.00
31 + coworking_industrious = 332.00
32 + claude_code = 200.00
33 + fastmail = 5.00
34 + domains = 2.00
35 + cloudflare = 0.00
36 + postmark = 0.00
37 + apple_developer_amortized = 8.25 # $99/yr ÷ 12
38 + co_llc_periodic_amortized = 0.83 # $10/yr ÷ 12
39 +
40 +
41 + # ─── Stripe fees (canonical: stripe_fees.md) ──────────────────────────────
42 + [stripe]
43 + percent = 0.029 # US standard online card
44 + fixed = 0.30 # Per-transaction flat fee, USD
45 + dispute_fee = 15.00 # Charged once at dispute creation
46 + instant_payout_us_pct = 0.015 # US/AU/NZ/AE region (was incorrectly cited as flat 1%)
47 + instant_payout_intl_pct = 0.010 # CA/EU/UK/SG/NO/HK/MY region
48 + instant_payout_min = 0.50
49 + chargeback_protection_pct = 0.004 # Stripe Radar add-on (fraud-only)
50 + tax_pct = 0.005 # Stripe Tax per transaction
51 +
52 + [stripe.connect_standard]
53 + # What MNW currently uses for creator subs.
54 + per_account_fee = 0
55 + per_payout_fee = 0
56 + payout_volume_pct = 0
57 +
58 + [stripe.connect_express]
59 + # Reference only — not used by MNW for creators.
60 + per_active_account = 2.00
61 + per_payout = 0.25
62 + payout_volume_pct = 0.0025
63 +
64 + [stripe.instant_payout]
65 + # Nested form of the flat instant_payout_* keys above; substituted into
66 + # legal/payments.md and guide/stripe.md as {{ stripe.instant_payout.* }}.
67 + # Kept alongside the flat form for backward compatibility.
68 + us_pct = 0.015
69 + intl_pct = 0.010
70 + min_usd = 0.50
71 +
72 +
73 + # ─── Policy commitments (canonical: guarantees.md) ───────────────────────
74 + # Time-based commitments that appear verbatim across guarantees.md, pricing.md,
75 + # tiers.md, faq.md, fan-guide.md, account-lifecycle.md, generative-ai.md, and
76 + # how-we-work.md. Semantically distinct even where the number matches today.
77 + [policy]
78 + buyer_access_days = 90 # guarantees §Buyer Access — post-close fan download window
79 + shutdown_notice_days = 90 # guarantees §Shutdown, §Continuity
80 + price_change_notice_days = 90 # guarantees §Price Stability, pricing.md
81 + grandfather_months = 12 # guarantees §Price Stability
82 + cancellation_grace_days = 30 # tiers.md §Cancellation, getting-started.md
83 + content_archive_months = 12 # guarantees §Content Archive (planned)
84 + generative_ai_notice_days = 30 # generative-ai.md §Governance
85 +
86 +
87 + # ─── Uptime (canonical: guarantees.md §Availability) ─────────────────────
88 + [uptime]
89 + target_pct = 0.995
90 + target_downtime_hours_year = 44 # (1 - 0.995) × 8760 ≈ 43.8, rounded up
91 +
92 +
93 + # ─── Broadcasts (canonical: tiers.md, mailing-lists.md) ──────────────────
94 + [broadcasts]
95 + per_day = 1
96 + recipients_per_send = 10000
97 + # Display string with thousands separator; docengine has no thousands filter
98 + # so the display form lives here (same pattern as `cohort.cap_display`).
99 + recipients_per_send_display = "10,000"
100 +
101 +
102 + # ─── Fan+ (canonical: fan-plus.md) ───────────────────────────────────────
103 + [fan_plus]
104 + monthly_price_usd = 8
105 + # Per-fan monthly credit allowance; funded by Fan+ subscription revenue,
106 + # not per-account overhead. Fan-only — creators don't receive it.
107 + monthly_credit_usd = 5
108 +
109 +
110 + # ─── Hetzner prices (canonical: hetzner_prices.md) ────────────────────────
111 + [hetzner]
112 + fx_eur_to_usd = 1.085 # Verify when FX moves >5%
113 +
114 + # Compute SKUs (EUR/mo)
115 + ccx13_eur = 13.10 # 2 dedicated vCPU / 8 GB / 80 GB — production
116 + ccx23_eur = 30.00 # 4 dedicated vCPU / 16 GB / 160 GB
117 + cpx11_eur = 3.85 # 2 shared vCPU / 2 GB / 40 GB
118 + cpx31_eur = 13.10 # 4 shared vCPU / 8 GB / 160 GB
119 + cx22_eur = 4.59 # 2 shared vCPU / 4 GB / 40 GB
120 +
121 + # Storage and bandwidth
122 + object_storage_eur_per_tb = 5.99
123 + object_storage_included_tb = 1
124 + egress_eur_per_tb_overage = 1.00
125 + egress_included_per_server_tb = 1
126 + volume_eur_per_gb = 0.044
127 + ipv4_eur = 0.60
128 + backup_pct_of_server = 0.20
129 +
130 +
131 + # ─── Tier prices (canonical: pricing.md) ──────────────────────────────────
132 + [tiers.founding]
133 + # Founder pricing — exactly 50% of standard sticker, locked for life. Active
134 + # until the founder window closes (1,000 creators OR exit-beta, whichever
135 + # first). Decision 2026-05-18; raised to 8/12/18/30 on 2026-05-31 to track
136 + # the standard-tier raise; reset to Refined-A (4/7/9/12) on 2026-07-08 per
137 + # pricing_interrogation_2026_07.md §6.
138 + basic = 4
139 + small_files = 7
140 + big_files = 9
141 + everything = 12
142 +
143 + [tiers.standard]
144 + # Refined-A sticker — set 2026-07-08 per pricing_interrogation_2026_07.md §6.
145 + # Priced against residency-scale labor ($41.5/hr Y1 support, ~$50/hr blended
146 + # engineering) rather than the earlier $80/hr market rate, with right-sized
147 + # Basic (10GB) and Small Files (100GB) envelopes. Founder rate is exactly
148 + # 50% of these. History: 16/24/36/60 (2026-05-31), 12/18/24/40 (earlier).
149 + basic = 8
150 + small_files = 14
151 + big_files = 18
152 + everything = 24
153 +
154 + # ─── Tier envelope limits (storage caps + file size caps) ────────────────
155 + # Per-tier file-size envelope. Public-facing copy substitutes these via
156 + # {{ tier_limits.* }} so the toml is the single source of truth and a future
157 + # limit change is a one-line edit. Stored as display strings (no NBSP, no
158 + # trailing periods) because that's the form the docs always need.
159 + #
160 + # `[tier_bytes]` below carries the same envelopes as machine-readable integer
161 + # byte counts — that's the form Rust needs for upload gating, storage-cap
162 + # accounting, and the metrics fill-ratio SQL. Docengine validates that each
163 + # `tier_bytes.<k>` parses back to the matching `tier_limits.<k>` string, so
164 + # a drift between the two forms fails at boot instead of shipping to prod.
165 + [tier_limits]
166 + # Refined-A envelopes (2026-07-08): Basic shrunk to 5MB/10GB and Small Files
167 + # total shrunk to 100GB to make the tier ladder honest to actual content shape
168 + # rather than "which tier gives me headroom I'll never use." Big Files and
169 + # Everything envelopes unchanged.
170 + basic_per_file = "5MB"
171 + basic_total = "10GB"
172 + small_files_per_file = "500MB"
173 + small_files_total = "100GB"
174 + big_files_per_file = "20GB"
175 + big_files_total = "500GB"
176 + everything_per_file = "20GB"
177 + everything_total = "500GB"
178 +
179 + [tier_bytes]
180 + # Binary units (KB = 1024 B). Must match [tier_limits] display strings above.
181 + basic_per_file = 5242880 # 5 MB
182 + basic_total = 10737418240 # 10 GB
183 + small_files_per_file = 524288000 # 500 MB
184 + small_files_total = 107374182400 # 100 GB
185 + big_files_per_file = 21474836480 # 20 GB
186 + big_files_total = 536870912000 # 500 GB
187 + everything_per_file = 21474836480 # 20 GB
188 + everything_total = 536870912000 # 500 GB
189 +
190 +
191 + [annual_discount]
192 + # Annual billing is 10% off the monthly × 12 total at every tier, founder
193 + # and standard. Two-digit discount, clean pitch. Decision 2026-05-18.
194 + #
195 + # What this actually covers:
196 + # - Stripe per-transaction fees ($0.30 each, charged 12x for monthly vs 1x
197 + # for annual): saves MNW ~$3.30/yr per customer regardless of tier.
198 + # - The 2.9% percent fee is identical either way (a wash).
199 + # - At Basic, 10% off ≈ the literal Stripe saving (close to pass-through).
200 + # - At Everything, 10% off ($36/yr) > the Stripe saving ($3.30/yr); MNW
201 + # absorbs the difference (~$15/yr per Everything customer at founder
202 + # pricing, ~$32/yr per Everything customer at sticker) as a cashflow +
203 + # reduced-billing-failure benefit. Defensible but not pure pass-through.
204 + #
205 + # Computed values (monthly × 12 × 0.9, rounded to nearest dollar):
206 + # Founder: $86 / $130 / $194 / $324
207 + # Standard: $173 / $259 / $389 / $648
208 + multiplier = 0.9
209 + months_equivalent_free = 1.2 # 10% of 12 months
210 +
211 +
212 + # ─── Tier mix (canonical: tier_mix.md) ────────────────────────────────────
213 + [tier_mix.assumed]
214 + # Pre-launch placeholder distribution. A4 in assumptions.md.
215 + # Updated monthly from SQL once creators exist.
216 + basic_pct = 0.40
217 + small_files_pct = 0.30
218 + big_files_pct = 0.20
219 + everything_pct = 0.10
220 +
221 +
222 + # ─── Reserve policy (canonical: reserve_policy.md) ────────────────────────
223 + [reserve]
224 + # OPEN: all values in this block per reserve_policy.md §8
225 +
226 + T_fixed_months = 12 # OPEN: §8 item 1
227 + S_legal = 50000 # OPEN: §8 item 2 — pre-quote
228 + S_shock = 5000 # OPEN: §8 item 3
229 +
230 + R_opp = 10000 # OPEN: §8 item 4
231 + rho_annual = 0.50 # OPEN: §8 item 5 — max % of R_opp/yr
232 + rho_incident = 0.25 # OPEN: §8 item 6 — max % of R_opp/decision
233 +
234 + surplus_split_reserve = 0.20 # OPEN: §8 item 7 — steady state
235 + surplus_split_earnback = 0.80
236 + # Sum must equal 1.0
237 +
238 + transition_buffer_pct = 1.00 # OPEN: §8 item 10 — multiplier on R_cap before personal-to-company transition
239 +
240 +
241 + # ─── Cohort (canonical: pricing.md) ───────────────────────────────────────
242 + [cohort]
243 + # OPEN: all values per pricing.md §7
244 +
245 + cap_count = 1000 # Decided 2026-05-31; raised from 500 to match public copy in tiers.md / pricing.md.
246 + cap_display = "1,000" # Display string with thousands separator; substituted via {{ cohort.cap_display }}. Keep in sync with cap_count.
247 + cap_months = 12 # OPEN: §7 item 1 — or first M months (whichever first)
248 + lock_duration = "lifetime" # OPEN: §7 item 4 — lifetime-of-subscription
249 + # Counting rule (cumulative vs active) — see sops/founding-cohort-tracking.md, founder decision pending
250 +
251 +
252 + # ─── Per-creator marginal cost inputs (canonical: assumptions.md A1-A10) ─
253 + # All currently unmeasured — pre-launch placeholders.
254 + [creator_marginal]
255 + storage_basic_gb = 0.05 # A1: ~50 MB (right-sized 2026-07-08 alongside 10GB Basic envelope)
256 + storage_small_files_gb = 2 # A2: ~2 GB
257 + storage_big_files_gb = 10 # A3: ~10 GB
258 + storage_everything_gb = 30 # A10: tier-dependent, placeholder
259 + storage_cost_per_gb_per_month = 0.0065 # = €5.99/TB-mo × FX
260 + egress_origin_hit_assumed_pct = 0.10 # A6: Cloudflare cache absorbs ~90% (assumed)
261 + chargeback_rate_tier_subs = 0.001 # A12: 0.1% for recurring (lower than one-shot)
262 +
263 +
264 + # ─── Derived values (for docengine feature; NOT stored — computed) ────────
265 + #
266 + # These are documented here for the future implementation. The docengine
267 + # feature should compute them from the values above. Listed as commented
268 + # pseudo-code; actual implementation belongs in Rust.
269 + #
270 + # R_cap = T_fixed_months · F_monthly + S_legal + S_shock
271 + # = 12 · 580 + 50000 + 5000 = 61960
272 + #
273 + # ARPU_founding = Σ (tier_mix[k] · tiers.founding[k])
274 + # = 0.4·8 + 0.3·12 + 0.2·18 + 0.1·30 = 13.40
275 + #
276 + # ARPU_standard = Σ (tier_mix[k] · tiers.standard[k])
277 + # = 0.4·16 + 0.3·24 + 0.2·36 + 0.1·60 = 26.80
278 + #
279 + # stripe_fee(amount) = stripe.percent · amount + stripe.fixed
280 + # stripe_fee_basic_std = 0.029 · 16 + 0.30 = 0.76
281 + # stripe_fee_small_std = 0.029 · 24 + 0.30 = 1.00
282 + # stripe_fee_big_std = 0.029 · 36 + 0.30 = 1.34
283 + # stripe_fee_ev_std = 0.029 · 60 + 0.30 = 2.04
284 + #
285 + # break_even(rate_class) = F_monthly / (ARPU_{rate_class} − marginal_avg)
286 + # break_even_standard = 580 / (26.80 − 1) ≈ 22.5 creators
287 + # break_even_founding = 580 / (13.40 − 1) ≈ 46.8 creators
288 + #
289 + # surplus(N, rate_class) = N · (ARPU − marginal) − F_monthly
290 + # surplus(100, "standard") = 100 · 25.80 − 580 = 2000
291 + # surplus(500, "standard") = 500 · 25.80 − 580 = 12320
292 + #
293 + # fill_time_months(N) = (R_cap + R_opp) / surplus(N, "standard")
294 + # fill_time(100) = 71960 / 2000 ≈ 36 mo
295 + # fill_time(500) = 71960 / 12320 ≈ 6 mo
296 + #
297 + # infra_cost(tier, N) = storage_{tier}_gb · storage_cost_per_gb_per_month
298 + # + egress_share + stripe_fee(price) + F_monthly/N
299 + #
300 + # Validation rules the build step should enforce:
301 + # - 100 < F_monthly < 10000 (typo guard)
302 + # - sum(tier_mix.*) = 1.00 (tier mix must sum to 100%)
303 + # - surplus_split_reserve + surplus_split_earnback = 1.00
304 + # - 0 < rho_annual ≤ 1
305 + # - 0 < rho_incident ≤ 1
306 + # - rho_incident ≤ rho_annual (single decision ≤ annual budget)
307 + # - All `tiers.founding[k]` ≤ `tiers.standard[k]`
308 + # - cap_count > 0, cap_months > 0
309 +
310 +
311 + # ─── Runway disclosure (transparency block on /docs/about/economics) ─────
312 + #
313 + # Operator-set. Refreshed quarterly at sprint close, alongside the
314 + # /changelog recap. The paying-creator count on the page is pulled live
315 + # from the DB; this number is the cash-runway bucket.
316 + #
317 + # Units: quarters at current burn (whole-number bucket; we round down
318 + # rather than mislead in either direction).
319 + #
320 + # A value of 0 means "not yet published" — the page renders without a
321 + # number until the first quarterly refresh sets it.
322 + [runway]
323 + # 17 = $190K committed funding ÷ $3,580/mo current actual burn (ops + $3K
324 + # salary), rounded down. Excludes the draw-on-request $50K reserve (would be
325 + # 22). Basis: server-internal/business/financial_dashboard.md. Refresh at
326 + # sprint close — bump if burn rises (Year 2 $5K salary) or funding changes.
327 + quarters = 17
328 + last_updated_iso = "2026-06-04"
329 +
330 +
331 + # ─── Pricing calculator (canonical: this file + stripe_fees.md) ───────────
332 + #
333 + # Powers the public /pricing comparison. Consumed by MNW's
334 + # `src/pricing_comparison.rs` (parsed directly from this file, not through
335 + # docengine's scalar lookup — arrays of tables aren't substitutable). The
336 + # net-revenue formula and every competitor's parameters live HERE, not in
337 + # client JS, so the public "you keep more" claim has one source of truth and
338 + # a pinning test (`pricing_comparison::tests`). Stripe fees are read from the
339 + # [stripe] block above (percent, fixed).
340 + #
341 + # Per-competitor net(revenue) is computed as:
342 + # txns = max(1, round(revenue / pricing_calc.avg_transaction))
343 + # pct = platform_pct_over if (platform_pct_threshold set AND rev > it)
344 + # else platform_pct
345 + # ap = revenue * (1 - pct) - txns * platform_per_tx
346 + # net = { none: ap
347 + # , percent: ap - ap*stripe.percent
348 + # , percent_and_fixed: ap - ap*stripe.percent - txns*stripe.fixed }
349 + # - flat_monthly
350 + # MNW's own row = platform_pct 0, percent_and_fixed, flat_monthly = tier price.
351 + #
352 + # Fee structures are each platform's public pricing as of 2026-06. Verify at
353 + # the linked page before editing; a change here updates the live page + test.
354 + [pricing_calc]
355 + avg_transaction = 25 # assumed average sale, for per-tx fee estimation
356 +
357 + [[competitors]]
358 + name = "Patreon"
359 + url = "https://www.patreon.com/pricing"
360 + fee_label = "10% + ~3% + $0.30"
361 + platform_pct = 0.10
362 + stripe_mode = "percent_and_fixed"
363 +
364 + [[competitors]]
365 + name = "Gumroad"
366 + url = "https://gumroad.com/pricing"
367 + fee_label = "10% + $0.50/tx + ~3%"
368 + platform_pct = 0.10
369 + platform_per_tx = 0.50
370 + stripe_mode = "percent"
371 +
372 + [[competitors]]
373 + name = "Bandcamp"
374 + url = "https://bandcamp.com/pricing"
375 + fee_label = "15% (10% after $5k) + ~3%"
376 + platform_pct = 0.15
377 + platform_pct_over = 0.10
378 + platform_pct_threshold = 5000
379 + stripe_mode = "percent"
380 +
381 + [[competitors]]
382 + name = "Ko-fi Free"
383 + url = "https://ko-fi.com/pricing"
384 + fee_label = "0% tips, 5% shop/memberships + ~3%"
385 + platform_pct = 0.05
386 + stripe_mode = "percent"
387 +
388 + [[competitors]]
389 + name = "Ko-fi Gold"
390 + url = "https://ko-fi.com/pricing"
391 + fee_label = "$12/mo + 0% + ~3%"
392 + flat_monthly = 12
393 + stripe_mode = "percent_and_fixed"
394 +
395 + [[competitors]]
396 + name = "Itch.io"
397 + url = "https://itch.io/docs/creators/faq"
398 + fee_label = "10% default + ~3%"
399 + platform_pct = 0.10
400 + stripe_mode = "percent"
401 +
402 + [[competitors]]
403 + name = "Lemon Squeezy"
404 + url = "https://www.lemonsqueezy.com/pricing"
405 + fee_label = "5% + $0.50/tx (incl. processing)"
406 + platform_pct = 0.05
407 + platform_per_tx = 0.50
408 + stripe_mode = "none"
409 +
410 + [[competitors]]
411 + name = "Buy Me a Coffee"
412 + url = "https://www.buymeacoffee.com/about"
413 + fee_label = "5% + ~3%"
414 + platform_pct = 0.05
415 + stripe_mode = "percent"
416 +
417 + [[competitors]]
418 + name = "Substack"
419 + url = "https://substack.com/going-paid"
420 + fee_label = "10% on paid subs"
421 + platform_pct = 0.10
422 + stripe_mode = "percent"