Skip to main content

max / makenotwork

server: /docs/economics is now an Askama page with live runway disclosure The retired markdown source is replaced by an Askama template at the same URL (/docs/economics) so inbound links don't break. Static-slug route registers before the catch-all /docs/{slug} so axum prefers the exact match. New disclosure section pulls live data: - count_active_paying: strict status='active' — revenue-bearing seats - count_trialing_or_grace: trialing + canceled-with-grace, shown only when non-zero so a quiet platform doesn't render "0 in trial" [runway] block in assumptions.toml carries the operator-set quarters figure + last_updated_iso stamp. quarters=0 is the "not yet published" sentinel and the template suppresses the bullet rather than rendering "0 quarters at current burn". Refresh quarterly at sprint close. RunwayConfig sibling to TierPrices/CostAllocation in tier_prices.rs; RunwayConfig::is_published gates the cash-runway line. Two new tier_prices tests pin the toml keys (so a rename catches at PR time) and the is_published gating; 1,665 lib tests passing.
Author: Max Johnson <me@maxj.phd> · 2026-06-04 04:33 UTC
Commit: e746b8d58e85996581ec5b3696bc08faa7e87aaa
Parent: 4347b1d
11 files changed, +288 insertions, -56 deletions
@@ -293,3 +293,19 @@ support = 5.00
293 293 engineering = 6.00
294 294 reserves = 7.50
295 295 earnback = 33.46
296 +
297 +
298 + # ─── Runway disclosure (transparency block on /docs/about/economics) ─────
299 + #
300 + # Operator-set. Refreshed quarterly at sprint close, alongside the
301 + # /changelog recap. The paying-creator count on the page is pulled live
302 + # from the DB; this number is the cash-runway bucket.
303 + #
304 + # Units: quarters at current burn (whole-number bucket; we round down
305 + # rather than mislead in either direction).
306 + #
307 + # A value of 0 means "not yet published" — the page renders without a
308 + # number until the first quarterly refresh sets it.
309 + [runway]
310 + quarters = 0
311 + last_updated_iso = "2026-06-03"
@@ -1,56 +0,0 @@
1 - # Platform Economics
2 -
3 - *As of 2026-05-16.*
4 -
5 - What you pay, what it covers, and what we commit to.
6 -
7 - ---
8 -
9 - ## What You Pay
10 -
11 - See [Pricing](./pricing.md) for the current tier prices, the founding-creator rate, and the provisional standard rate.
12 -
13 - ---
14 -
15 - ## What It Covers
16 -
17 - Your subscription funds three things, at a high level:
18 -
19 - **Cost to deliver.** The infrastructure that hosts your content (application servers, database, object storage, bandwidth) and the payment processing fee Stripe charges on each transaction.
20 -
21 - **Platform overhead.** Ongoing development, day-to-day operations, legal and compliance work, and reserves held against unexpected costs so a bad month doesn't force a price increase or a shutdown.
22 -
23 - **Returned to creators.** Surplus beyond what cost-to-deliver and platform overhead require is earmarked to come back to you as earn-back credit. We've committed to launching that program no later than 2027-01-01.
24 -
25 - See the full breakdown on the [pricing page](/pricing#cost-allocation) — every tier fee broken into Stripe processing, storage, human support time, product engineering, reserves, and earn-back surplus.
26 -
27 - No surplus goes to investors, shareholders, dividends, executive bonuses, paid acquisition, or marketing spend. We have none of those things.
28 -
29 - ---
30 -
31 - ## What We Commit To
32 -
33 - The binding commitments live in [What We Guarantee](./guarantees.md). The short version:
34 -
35 - - No surprise raises. Prices only change under conditions stated in writing.
36 - - At least 90 days notice on any change.
37 - - Existing creators are grandfathered at their current rate for at least 12 months when standard rates rise.
38 - - When our cost structure permits, the direction is downward. Cost-favorable changes apply retroactively to active subscriptions.
39 -
40 - ---
41 -
42 - ## What We Won't Do
43 -
44 - - **Percentage cuts.** We will not skim a percentage of what fans pay you.
45 - - **Per-transaction fees on top of Stripe.** The only deduction is Stripe's processing fee, which goes to Stripe.
46 - - **Surge pricing.** Tier prices do not move based on demand, time of day, or who you are.
47 - - **Quiet plan-gating.** Features won't migrate between tiers without notice and grandfathering.
48 - - **Paid acquisition or marketing.** We don't fund growth by spending your subscription on ads.
49 -
50 - ---
51 -
52 - ## See Also
53 -
54 - - [Pricing](./pricing.md): Current rates, founding-creator rate, standard rate
55 - - [What We Guarantee](./guarantees.md): Binding commitments in writing
56 - - [How We Work](./how-we-work.md): Business model and operating principles
@@ -472,6 +472,47 @@ pub async fn mark_grace_enforced(pool: &PgPool, user_id: UserId) -> Result<()> {
472 472 Ok(())
473 473 }
474 474
475 + /// Count fully-paying creators — `status = 'active'` only.
476 + ///
477 + /// Excludes trialing (free trial), past_due (payment failed but not yet
478 + /// canceled), canceled-in-grace (winding down), and incomplete states.
479 + /// This is the number that goes on the runway disclosure as "paying
480 + /// creators today": revenue-bearing seats, no fudge.
481 + #[tracing::instrument(skip_all)]
482 + pub async fn count_active_paying(pool: &PgPool) -> Result<i64> {
483 + let count: (i64,) = sqlx::query_as(
484 + "SELECT COUNT(*) FROM creator_subscriptions WHERE status = 'active'",
485 + )
486 + .fetch_one(pool)
487 + .await?;
488 + Ok(count.0)
489 + }
490 +
491 + /// Count creators in a trial or 30-day cancellation grace period.
492 + ///
493 + /// These are not revenue-bearing today but represent the near-term
494 + /// pipeline: trialing seats may convert, grace seats may resubscribe
495 + /// before enforcement. Disclosed as a secondary number on the runway
496 + /// surface so the headline `count_active_paying` stays strict.
497 + #[tracing::instrument(skip_all)]
498 + pub async fn count_trialing_or_grace(pool: &PgPool) -> Result<i64> {
499 + let count: (i64,) = sqlx::query_as(
500 + r#"
501 + SELECT COUNT(*) FROM creator_subscriptions
502 + WHERE status = 'trialing'
503 + OR (
504 + status = 'canceled'
505 + AND canceled_at IS NOT NULL
506 + AND canceled_at > NOW() - INTERVAL '30 days'
507 + AND grace_enforced_at IS NULL
508 + )
509 + "#,
510 + )
511 + .fetch_one(pool)
512 + .await?;
513 + Ok(count.0)
514 + }
515 +
475 516 /// Check whether a user is in the 30-day cancellation grace period.
476 517 ///
477 518 /// Returns `true` if the subscription is canceled but within 30 days of cancellation
@@ -76,6 +76,7 @@ pub struct AppState {
76 76 pub docs: Arc<DocLoader>,
77 77 pub tier_prices: tier_prices::TierPrices,
78 78 pub cost_allocation: tier_prices::CostAllocation,
79 + pub runway_config: tier_prices::RunwayConfig,
79 80 pub scanner: Option<Arc<ScanPipeline>>,
80 81 pub webauthn: Arc<Webauthn>,
81 82 pub syntax: Option<Arc<git::SyntaxHighlighter>>,
@@ -322,6 +322,7 @@ async fn main() {
322 322 let tp = makenotwork::tier_prices::TierPrices::from_assumptions(&assumptions);
323 323 makenotwork::tier_prices::CostAllocation::from_assumptions(&assumptions, &tp)
324 324 },
325 + runway_config: makenotwork::tier_prices::RunwayConfig::from_assumptions(&assumptions),
325 326 scanner,
326 327 webauthn,
327 328 syntax,
@@ -360,6 +360,33 @@ pub(super) async fn pricing_page(
360 360 }
361 361 }
362 362
363 + /// Render the platform-economics + runway disclosure page.
364 + ///
365 + /// Shadows the catch-all `/docs/{slug}` so this URL keeps the
366 + /// `/docs/economics` shape inbound links already use, but the page
367 + /// renders as Askama (not docengine markdown) so it can carry live
368 + /// figures from the database. The two count queries are cheap (each
369 + /// is a single `SELECT COUNT(*)` against an indexed status column);
370 + /// no caching needed at current load.
371 + #[tracing::instrument(skip_all, name = "landing::economics_page")]
372 + pub(super) async fn economics_page(
373 + State(state): State<AppState>,
374 + session: Session,
375 + MaybeUserUnverified(maybe_user): MaybeUserUnverified,
376 + ) -> Result<impl IntoResponse> {
377 + let paying_creators =
378 + crate::db::creator_tiers::count_active_paying(&state.db).await?;
379 + let trialing_or_grace =
380 + crate::db::creator_tiers::count_trialing_or_grace(&state.db).await?;
381 + Ok(EconomicsTemplate {
382 + csrf_token: get_csrf_token(&session).await,
383 + session_user: maybe_user,
384 + runway_config: state.runway_config.clone(),
385 + paying_creators,
386 + trialing_or_grace,
387 + })
388 + }
389 +
363 390 /// Lightweight checkout success page for app-initiated Stripe flows.
364 391 /// No auth required; the app polls for subscription status independently.
365 392 #[tracing::instrument(skip_all, name = "landing::checkout_complete")]
@@ -90,6 +90,10 @@ pub fn public_routes() -> CsrfRouter<AppState> {
90 90 .route_get("/creators", get(creators_page))
91 91 .route_get("/docs", get(docs::docs_index))
92 92 .route_get("/docs/search.json", get(docs::docs_search_index))
93 + // Static-slug routes must register BEFORE the catch-all `/docs/{slug}`
94 + // so axum's router prefers the exact match. /docs/economics renders
95 + // as Askama (live runway disclosure); the markdown source is gone.
96 + .route_get("/docs/economics", get(landing::economics_page))
93 97 .route_get("/docs/{slug}", get(docs::doc_page))
94 98 // Two-factor authentication
95 99 .route_get("/auth/2fa", get(two_factor::two_factor_page))
@@ -87,6 +87,8 @@ impl_into_response!(
87 87 DocIndexTemplate,
88 88 // Pricing calculator
89 89 PricingTemplate,
90 + // Platform economics + runway disclosure
91 + EconomicsTemplate,
90 92 // Use cases
91 93 UseCasesTemplate,
92 94 // Team
@@ -771,6 +771,29 @@ pub struct PricingTemplate {
771 771 pub cost_allocation: crate::tier_prices::CostAllocation,
772 772 }
773 773
774 + /// Platform economics + runway disclosure page. Renders at
775 + /// `/docs/about/economics` and shadows the catch-all docs route so the
776 + /// URL stays the same as the retired markdown page. See the doc comment
777 + /// on `templates/pages/economics.html` for the maintenance contract.
778 + #[derive(Template)]
779 + #[template(path = "pages/economics.html")]
780 + #[allow(dead_code)] // Fields used by Askama template
781 + pub struct EconomicsTemplate {
782 + pub csrf_token: CsrfTokenOption,
783 + pub session_user: Option<SessionUser>,
784 + /// `quarters` + `last_updated_iso` from `[runway]` in
785 + /// `assumptions.toml`. Operator-edited; refreshed quarterly.
786 + pub runway_config: crate::tier_prices::RunwayConfig,
787 + /// Live count of `status='active'` creator subscriptions. Pulled at
788 + /// request time so the page never lies about how many seats are
789 + /// revenue-bearing right now.
790 + pub paying_creators: i64,
791 + /// Live count of `status='trialing'` plus canceled-with-grace
792 + /// creators. Disclosed only when non-zero (the template hides the
793 + /// bullet otherwise so a quiet platform doesn't show "0 in trial").
794 + pub trialing_or_grace: i64,
795 + }
796 +
774 797 /// Use cases page showcasing creator types.
775 798 #[derive(Template)]
776 799 #[template(path = "pages/use_cases.html")]
@@ -255,6 +255,38 @@ fn build_aria_label(tier_label: &str, price: i32, segments: &[CostSegment]) -> S
255 255 s
256 256 }
257 257
258 + /// Operator-edited runway figures, loaded once at startup. The
259 + /// live paying-creator counts come from the DB at request time and
260 + /// are NOT in this struct — see `db::creator_tiers::count_active_paying`
261 + /// and `count_trialing_or_grace`.
262 + ///
263 + /// `quarters` is the cash-runway bucket in whole quarters (rounded down).
264 + /// A value of `0` means "not yet published" and the template should
265 + /// suppress the line rather than render "0 quarters".
266 + ///
267 + /// `last_updated_iso` is the date the operator last refreshed the figure,
268 + /// in ISO 8601 (`YYYY-MM-DD`). Rendered verbatim into the "Last updated"
269 + /// stamp on the disclosure surface.
270 + #[derive(Clone, Debug, Default)]
271 + pub struct RunwayConfig {
272 + pub quarters: i32,
273 + pub last_updated_iso: String,
274 + }
275 +
276 + impl RunwayConfig {
277 + pub fn from_assumptions(a: &Assumptions) -> Self {
278 + Self {
279 + quarters: int_at(a, "runway.quarters"),
280 + last_updated_iso: str_at(a, "runway.last_updated_iso"),
281 + }
282 + }
283 + /// True iff the operator has published a runway figure. Suppress the
284 + /// "X quarters at current burn" line when this is false.
285 + pub fn is_published(&self) -> bool {
286 + self.quarters > 0
287 + }
288 + }
289 +
258 290 fn float_at(a: &Assumptions, key: &str) -> f64 {
259 291 match a.get(key) {
260 292 Some(LookupValue::Float(x)) => *x,
@@ -332,6 +364,34 @@ mod tests {
332 364 }
333 365
334 366 #[test]
367 + fn runway_config_loads_from_canonical_assumptions() {
368 + // The presence of the [runway] block is the only enforced thing —
369 + // the values inside are operator-edited. We pin the keys so a
370 + // future toml edit that renames `quarters` or `last_updated_iso`
371 + // is caught at PR time, not at boot.
372 + let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml");
373 + let r = RunwayConfig::from_assumptions(&a);
374 + assert!(r.quarters >= 0, "quarters must be a non-negative integer");
375 + assert!(!r.last_updated_iso.is_empty(), "last_updated_iso must be set");
376 + // ISO 8601 date format: YYYY-MM-DD.
377 + assert_eq!(r.last_updated_iso.len(), 10);
378 + assert!(r.last_updated_iso.chars().nth(4) == Some('-'));
379 + assert!(r.last_updated_iso.chars().nth(7) == Some('-'));
380 + }
381 +
382 + #[test]
383 + fn runway_config_is_published_only_when_quarters_nonzero() {
384 + // The disclosure template hides the cash-runway bullet when this
385 + // returns false, so a freshly-deployed instance with quarters=0
386 + // doesn't display "0 quarters at current burn" — which would be
387 + // both wrong and alarming.
388 + let r = RunwayConfig { quarters: 0, last_updated_iso: "2026-06-03".into() };
389 + assert!(!r.is_published());
390 + let r = RunwayConfig { quarters: 4, last_updated_iso: "2026-06-03".into() };
391 + assert!(r.is_published());
392 + }
393 +
394 + #[test]
335 395 fn cost_allocation_aria_label_names_every_segment() {
336 396 // Screen readers get the full breakdown via aria-label since the
337 397 // colored bar carries no native semantics. Pin the format.
@@ -0,0 +1,113 @@
1 + {# Platform Economics — Askama page, NOT a docengine markdown surface.
2 +
3 + This page carries live disclosure (paying-creator counts pulled from
4 + the DB at request time) which docengine's static markdown pipeline
5 + can't express. It shadows the catch-all /docs/{slug} route so the
6 + URL stays at /docs/about/economics for backwards-compatibility with
7 + inbound links.
8 +
9 + Prose mirrors the previous economics.md content (deleted in the same
10 + commit). To edit prose, edit this file. To edit the cost-allocation
11 + model or the runway figure, edit docs/business/assumptions.toml. #}
12 + {% extends "base.html" %}
13 +
14 + {% block title %}Platform Economics - Makenot.work{% endblock %}
15 +
16 + {% block head %}{% endblock %}
17 +
18 + {% block content %}
19 + {% include "partials/site_header.html" %}
20 +
21 + <article class="doc-container">
22 + <nav class="doc-breadcrumb">
23 + <a href="/docs">Docs</a> / <span>About</span>
24 + <div class="docs-search-container docs-search-inline">
25 + <input type="text" class="docs-search-input" placeholder="Search docs..." autocomplete="off" />
26 + <div class="docs-search-results hidden"></div>
27 + </div>
28 + </nav>
29 +
30 + <h1 class="doc-title">Platform Economics</h1>
31 +
32 + <div class="doc-body">
33 + <p><em>As of {{ runway_config.last_updated_iso }}.</em></p>
34 +
35 + <p>What you pay, what it covers, the position we're operating from, and what we commit to.</p>
36 +
37 + <hr>
38 +
39 + <h2>What You Pay</h2>
40 + <p>See <a href="/docs/pricing">Pricing</a> for the current tier prices, the founding-creator rate, and the provisional standard rate.</p>
41 +
42 + <hr>
43 +
44 + <h2>What It Covers</h2>
45 + <p>Your subscription funds three things, at a high level:</p>
46 +
47 + <p><strong>Cost to deliver.</strong> The infrastructure that hosts your content (application servers, database, object storage, bandwidth) and the payment processing fee Stripe charges on each transaction.</p>
48 +
49 + <p><strong>Platform overhead.</strong> Ongoing development, day-to-day operations, legal and compliance work, and reserves held against unexpected costs so a bad month doesn't force a price increase or a shutdown.</p>
50 +
51 + <p><strong>Returned to creators.</strong> Surplus beyond what cost-to-deliver and platform overhead require is earmarked to come back to you as earn-back credit. We've committed to launching that program no later than 2027-01-01.</p>
52 +
53 + <p>See the full breakdown on the <a href="/pricing#cost-allocation">pricing page</a> — every tier fee broken into Stripe processing, storage, human support time, product engineering, reserves, and earn-back surplus.</p>
54 +
55 + <p>No surplus goes to investors, shareholders, dividends, executive bonuses, paid acquisition, or marketing spend. We have none of those things.</p>
56 +
57 + <hr>
58 +
59 + <h2>Where We Are</h2>
60 + <p>Numbers refresh at the close of every two-month sprint, alongside the <a href="/changelog">changelog</a> recap. The paying-creator count is read live from the database at the time you load this page.</p>
61 +
62 + <ul>
63 + <li>Paying creators today: <strong>{{ paying_creators }}</strong></li>
64 + {% if trialing_or_grace > 0 %}
65 + <li>In trial or 30-day cancellation grace: <strong>{{ trialing_or_grace }}</strong></li>
66 + {% endif %}
67 + <li>Break-even at the assumed tier mix: approximately <strong>22.5</strong> paying creators at the standard rate, <strong>46.8</strong> at the founding rate. (Derivation in the build's <code>assumptions.toml</code>.)</li>
68 + {% if runway_config.is_published() %}
69 + <li>Cash runway at current burn: <strong>{{ runway_config.quarters }}</strong> quarter{% if runway_config.quarters != 1 %}s{% endif %}.</li>
70 + {% endif %}
71 + </ul>
72 +
73 + <p>Reserve capacity (twelve months of fixed costs plus a legal reserve and a single-incident shock reserve) is approximately <strong>$62k</strong>, derived from the build's assumptions. Reserves shrink before prices change; a single bad month doesn't move the headline rate.</p>
74 +
75 + <hr>
76 +
77 + <h2>What We Commit To</h2>
78 + <p>The binding commitments live in <a href="/docs/guarantees">What We Guarantee</a>. The short version:</p>
79 + <ul>
80 + <li>No surprise raises. Prices only change under conditions stated in writing.</li>
81 + <li>At least 90 days notice on any change.</li>
82 + <li>Existing creators are grandfathered at their current rate for at least 12 months when standard rates rise.</li>
83 + <li>When our cost structure permits, the direction is downward. Cost-favorable changes apply retroactively to active subscriptions.</li>
84 + </ul>
85 +
86 + <hr>
87 +
88 + <h2>What We Won't Do</h2>
89 + <ul>
90 + <li><strong>Percentage cuts.</strong> We will not skim a percentage of what fans pay you.</li>
91 + <li><strong>Per-transaction fees on top of Stripe.</strong> The only deduction is Stripe's processing fee, which goes to Stripe.</li>
92 + <li><strong>Surge pricing.</strong> Tier prices do not move based on demand, time of day, or who you are.</li>
93 + <li><strong>Quiet plan-gating.</strong> Features won't migrate between tiers without notice and grandfathering.</li>
94 + <li><strong>Paid acquisition or marketing.</strong> We don't fund growth by spending your subscription on ads.</li>
95 + </ul>
96 +
97 + <hr>
98 +
99 + <h2>See Also</h2>
100 + <ul>
101 + <li><a href="/docs/pricing">Pricing</a>: Current rates, founding-creator rate, standard rate</li>
102 + <li><a href="/docs/guarantees">What We Guarantee</a>: Binding commitments in writing</li>
103 + <li><a href="/docs/how-we-work">How We Work</a>: Business model and operating principles</li>
104 + </ul>
105 + </div>
106 + </article>
107 +
108 + <footer class="text-reader-footer">
109 + <a href="/">Makenot<span class="dot">.</span>work</a>
110 + </footer>
111 +
112 + <script src="/static/docs-search.js"></script>
113 + {% endblock %}