Skip to main content

max / makenotwork

Split assumption substitution out of docengine into subst + mnw-assumptions docengine had grown a business-model calculator with a templater bolted on. Neither half belonged in a markdown renderer, so both leave: - `subst` is the generic `{{ dotted.path | filter(args) }}` engine. Value table, filter registry, mini-parser, code-span skipping. Depends on regex-lite and nothing else. - `mnw-assumptions` is the MNW layer: the typed mirror of assumptions.toml, the derived-value registry, the validation rules, and the substitute_dir operator bin. Re-exports subst's surface, keeping `LookupValue` as the historical name so consumers only swapped an import. The render path is unchanged. The server builds an Assumptions at boot and hands its `substitute` to DocLoaderConfig::pre_process, which is the seam any pre-render text transform would use. docengine loses its `assumptions` feature, its toml/regex-lite coupling to it, and 2,085 lines. Also lands the work that motivated pulling the thread: an assumptions block mirroring the SyncKit billing constants, so the public developer docs substitute the storage rate, invoice floor, and cap bounds instead of hardcoding them, plus a bare-value lint over the site-docs corpus that fails when a linted number is typed by hand, and a drift guard asserting the toml still matches synckit_billing.rs.
Author: Max Johnson <me@maxj.phd> · 2026-07-25 22:13 UTC
Signed with PGP, not checked
Commit: 09345243725c96030ba324a82f2b608caae49235
Parent: 577fe7a
29 files changed, +2178 insertions, -896 deletions
@@ -4399,6 +4399,7 @@
4399 4399 "memmap2",
4400 4400 "metrics",
4401 4401 "metrics-exporter-prometheus",
4402 + "mnw-assumptions",
4402 4403 "object 0.39.1",
4403 4404 "openssl",
4404 4405 "pom-contract",
@@ -4670,6 +4671,15 @@
4670 4671 "windows-sys 0.61.2",
4671 4672 ]
4672 4673
4674 + [[package]]
4675 + name = "mnw-assumptions"
4676 + version = "0.1.0"
4677 + dependencies = [
4678 + "serde",
4679 + "subst",
4680 + "toml 1.1.3+spec-1.1.0",
4681 + ]
4682 +
4673 4683 [[package]]
4674 4684 name = "moxcms"
4675 4685 version = "0.8.1"
@@ -7180,6 +7190,13 @@
7180 7190 "syn 2.0.118",
7181 7191 ]
7182 7192
7193 + [[package]]
7194 + name = "subst"
7195 + version = "0.1.0"
7196 + dependencies = [
7197 + "regex-lite",
7198 + ]
7199 +
7183 7200 [[package]]
7184 7201 name = "subtle"
7185 7202 version = "2.6.1"
@@ -118,7 +118,8 @@
118 118 metrics-exporter-prometheus = { version = "0.18.1", default-features = false }
119 119
120 120 # Markdown rendering + documentation engine
121 - docengine = { path = "../shared/docengine", features = ["doc-loader", "directives", "frontmatter", "media-urls", "assumptions"] }
121 + docengine = { path = "../shared/docengine", features = ["doc-loader", "directives", "frontmatter", "media-urls"] }
122 + mnw-assumptions = { path = "../shared/mnw-assumptions" }
122 123
123 124 # Tag standard
124 125 tagtree = { path = "../shared/tagtree" }
@@ -12,7 +12,7 @@
12 12 use tower_sessions_sqlx_store::PostgresStore;
13 13 use tracing_subscriber::{Layer, layer::SubscriberExt, util::SubscriberInitExt};
14 14
15 - use docengine::{Assumptions, DocLoader, DocLoaderConfig};
15 + use docengine::{DocLoader, DocLoaderConfig};
16 16 use makenotwork::config::Config;
17 17 use makenotwork::constants;
18 18 use makenotwork::email::{EmailClient, EmailConfig};
@@ -20,6 +20,7 @@
20 20 use makenotwork::scanning::ScanPipeline;
21 21 use makenotwork::storage::S3Client;
22 22 use makenotwork::{AppState, AppStorage, build_app};
23 + use mnw_assumptions::Assumptions;
23 24 use webauthn_rs::WebauthnBuilder;
24 25
25 26 #[tokio::main]
@@ -16,7 +16,7 @@
16 16
17 17 use std::sync::OnceLock;
18 18
19 - use docengine::{Assumptions, LookupValue};
19 + use mnw_assumptions::{Assumptions, LookupValue};
20 20
21 21 use crate::db::CreatorTier;
22 22
@@ -4,10 +4,13 @@
4 4 //! - `docs/business/assumptions.toml` parses
5 5 //! - All consistency rules pass (sums, bounds, founding ≤ standard)
6 6 //! - Every `{{ ... }}` marker in the live site-docs corpus resolves
7 + //! - No *bare* copy of a linted assumption value survives in the corpus
8 + //! - The `[synckit]` mirror still matches the authoritative Rust constants
7 9 //!
8 10 //! Run with: cargo test --test assumptions
9 11
10 - use docengine::Assumptions;
12 + use makenotwork::synckit_billing;
13 + use mnw_assumptions::{Assumptions, code_span_ranges};
11 14
12 15 // Canonical assumptions.toml ships with the repo at server/docs/business/.
13 16 // Cargo runs tests with cwd = crate manifest dir (MNW/server/), so the
@@ -45,6 +48,283 @@
45 48 );
46 49 }
47 50
51 + /// `[synckit]` in assumptions.toml mirrors constants that live in Rust, so the
52 + /// public developer docs can substitute them. Rust is authoritative; this test
53 + /// is the drift guard, the same shape as the `tier_limits` ↔ `tier_bytes` rule
54 + /// inside `Assumptions::validate`.
55 + #[test]
56 + fn synckit_block_mirrors_billing_constants() {
57 + let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load");
58 + let num = |key: &str| {
59 + a.get(key)
60 + .unwrap_or_else(|| panic!("missing key {key}"))
61 + .as_f64()
62 + .unwrap_or_else(|| panic!("{key} is not numeric"))
63 + };
64 +
65 + // Both sides are money, so compare in whole cents rather than comparing
66 + // f64s for equality.
67 + let cents = |key: &str| (num(key) * 100.0).round() as i64;
68 +
69 + assert_eq!(
70 + cents("synckit.storage_usd_per_gb_month"),
71 + synckit_billing::STORAGE_RATE_CENTS_PER_GB,
72 + "synckit.storage_usd_per_gb_month != STORAGE_RATE_CENTS_PER_GB"
73 + );
74 + assert_eq!(
75 + cents("synckit.invoice_floor_usd"),
76 + synckit_billing::BASE_FLOOR_CENTS,
77 + "synckit.invoice_floor_usd != BASE_FLOOR_CENTS"
78 + );
79 + assert_eq!(
80 + num("synckit.max_priced_gb").round() as i64,
81 + synckit_billing::MAX_STORAGE_GB,
82 + "synckit.max_priced_gb != MAX_STORAGE_GB"
83 + );
84 + }
85 +
86 + // --- bare-value lint
87 +
88 + /// How a linted key renders when a writer types it by hand. Each variant maps
89 + /// to the marker + filter a doc would use, so the searched-for literal is
90 + /// exactly what substitution would have produced.
91 + #[derive(Copy, Clone)]
92 + enum Form {
93 + /// `{{ k | money }}` — `$0.30`.
94 + Money,
95 + /// `{{ k | percent }}` — `2.9%`.
96 + Percent,
97 + /// `${{ k }}` — `$580`, plus the comma-grouped hand-written form.
98 + Dollars,
99 + /// `{{ k }}` — the value verbatim (already a display string).
100 + Verbatim,
101 + }
102 +
103 + /// Assumption values distinctive enough that a bare occurrence in the corpus
104 + /// is a stale-copy bug rather than a coincidence.
105 + ///
106 + /// Deliberately NOT linted: the tier prices and Fan+ prices (small dollar
107 + /// amounts like `$8` collide with illustrative sale prices all over the guide),
108 + /// the tier envelope sizes (`5 MB` is also the sandbox per-file cap, `500 MB`
109 + /// the export cap — different limits that happen to share a number), and
110 + /// `uptime.target_downtime_hours_year` (a bare `44` is noise). Those still
111 + /// carry markers in the corpus; they just can't be policed by string search.
112 + const LINTED: &[(&str, Form)] = &[
113 + ("expenses.F_monthly", Form::Dollars),
114 + ("stripe.percent", Form::Percent),
115 + ("stripe.fixed", Form::Money),
116 + ("stripe.dispute_fee", Form::Money),
117 + ("uptime.target_pct", Form::Percent),
118 + ("derived.R_cap", Form::Dollars),
119 + ("cohort.cap_display", Form::Verbatim),
120 + ("broadcasts.recipients_per_send_display", Form::Verbatim),
121 + ("synckit.storage_usd_per_gb_month", Form::Money),
122 + ("synckit.invoice_floor_usd", Form::Money),
123 + ("derived.annual_founding_basic", Form::Dollars),
124 + ("derived.annual_founding_small_files", Form::Dollars),
125 + ("derived.annual_founding_big_files", Form::Dollars),
126 + ("derived.annual_founding_everything", Form::Dollars),
127 + ("derived.annual_standard_basic", Form::Dollars),
128 + ("derived.annual_standard_small_files", Form::Dollars),
129 + ("derived.annual_standard_big_files", Form::Dollars),
130 + ("derived.annual_standard_everything", Form::Dollars),
131 + ];
132 +
133 + /// Lines that legitimately carry a linted literal. Matched as
134 + /// (path suffix, substring of the offending line, reason) — a substring rather
135 + /// than a line number so ordinary edits above don't invalidate the waiver.
136 + const EXEMPT: &[(&str, &str, &str)] = &[
137 + (
138 + "guide/git.md",
139 + "10,000 lines",
140 + "commit-diff display cap, unrelated to the broadcast recipient cap",
141 + ),
142 + (
143 + "guide/items.md",
144 + "10,000 characters",
145 + "changelog field length, unrelated to the broadcast recipient cap",
146 + ),
147 + (
148 + "about/compare.md",
149 + "1,000 sales",
150 + "illustrative transaction count in a revenue example, not the cohort cap",
151 + ),
152 + (
153 + "about/how-we-work.md",
154 + "1,000 transactions",
155 + "illustrative transaction count in a revenue example, not the cohort cap",
156 + ),
157 + (
158 + "guide/stripe.md",
159 + "| Canada |",
160 + "Canadian Stripe rate (C$), coincidentally the same digits as the US rate; \
161 + only the US row is ours to substitute",
162 + ),
163 + (
164 + "guide/stripe.md",
165 + "| Australia |",
166 + "Australian Stripe rate (A$), not the US flat fee",
167 + ),
168 + ];
169 +
170 + #[test]
171 + fn no_bare_assumption_values_in_site_docs() {
172 + let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load");
173 +
174 + // Render each linted key through the engine itself, so the literal we hunt
175 + // for is byte-identical to what the marker would have emitted.
176 + let mut needles: Vec<(&str, String)> = Vec::new();
177 + for &(key, form) in LINTED {
178 + assert!(a.get(key).is_some(), "linted key {key} is not in the table");
179 + let render = |expr: &str| {
180 + a.substitute(&format!("{{{{ {expr} }}}}"))
181 + .unwrap_or_else(|e| panic!("cannot render {expr}: {e}"))
182 + };
183 + match form {
184 + Form::Money => needles.push((key, render(&format!("{key} | money")))),
185 + Form::Percent => needles.push((key, render(&format!("{key} | percent")))),
186 + Form::Verbatim => needles.push((key, render(key))),
187 + Form::Dollars => {
188 + let plain = render(key);
189 + needles.push((key, format!("${plain}")));
190 + let grouped = group_thousands(&plain);
191 + if grouped != plain {
192 + needles.push((key, format!("${grouped}")));
193 + }
194 + }
195 + }
196 + }
197 +
198 + let mut findings = Vec::new();
199 + visit_markdown(std::path::Path::new(SITE_DOCS_PATH), &mut |path, body| {
200 + let display = path.display().to_string();
201 + let code = code_span_ranges(body);
202 + let prose = strip_ranges(body, &code);
203 + let prose = strip_markers(&prose);
204 +
205 + for (lineno, line) in prose.lines().enumerate() {
206 + for (key, needle) in &needles {
207 + if !contains_bare(line, needle) {
208 + continue;
209 + }
210 + if EXEMPT
211 + .iter()
212 + .any(|(file, snippet, _)| display.ends_with(file) && line.contains(snippet))
213 + {
214 + continue;
215 + }
216 + findings.push(format!(
217 + " {display}:{}: bare {needle:?} — use a {{{{ {key} }}}} marker\n {}",
218 + lineno + 1,
219 + line.trim()
220 + ));
221 + }
222 + }
223 + });
224 +
225 + assert!(
226 + findings.is_empty(),
227 + "{} bare assumption value(s) in the corpus \
228 + (substitute them, or add an EXEMPT entry with a reason):\n{}",
229 + findings.len(),
230 + findings.join("\n")
231 + );
232 + }
233 +
234 + /// A waiver that no longer matches anything is a waiver that should be
235 + /// deleted, not carried. Keeps `EXEMPT` from silently outliving its reason.
236 + #[test]
237 + fn every_exemption_still_matches_a_line() {
238 + let mut stale = Vec::new();
239 + for (file, snippet, reason) in EXEMPT {
240 + let path = std::path::Path::new(SITE_DOCS_PATH).join(file);
241 + let matched = std::fs::read_to_string(&path)
242 + .is_ok_and(|body| body.lines().any(|line| line.contains(*snippet)));
243 + if !matched {
244 + stale.push(format!(
245 + " {file}: {snippet:?} no longer present ({reason})"
246 + ));
247 + }
248 + }
249 + assert!(
250 + stale.is_empty(),
251 + "{} stale bare-value exemption(s), delete them:\n{}",
252 + stale.len(),
253 + stale.join("\n")
254 + );
255 + }
256 +
257 + /// Blank out byte ranges (code spans, `{{ … }}` markers) with spaces, keeping
258 + /// every other byte at its original offset so line numbers stay truthful.
259 + fn strip_ranges(text: &str, ranges: &[(usize, usize)]) -> String {
260 + let mut out: Vec<u8> = text.as_bytes().to_vec();
261 + for &(start, end) in ranges {
262 + for b in &mut out[start..end.min(text.len())] {
263 + if *b != b'\n' {
264 + *b = b' ';
265 + }
266 + }
267 + }
268 + String::from_utf8(out).expect("only ASCII bytes replaced")
269 + }
270 +
271 + /// Blank out `{{ … }}` markers: a value inside one is substituted, not bare.
272 + fn strip_markers(text: &str) -> String {
273 + let mut ranges = Vec::new();
274 + let mut rest = text;
275 + let mut base = 0;
276 + while let Some(open) = rest.find("{{") {
277 + let after = open + 2;
278 + match rest[after..].find("}}") {
279 + Some(rel) => {
280 + let close = after + rel + 2;
281 + ranges.push((base + open, base + close));
282 + base += close;
283 + rest = &rest[close..];
284 + }
285 + // Unclosed marker: nothing further to blank.
286 + None => break,
287 + }
288 + }
289 + strip_ranges(text, &ranges)
290 + }
291 +
292 + /// True when `needle` occurs in `line` as a whole number, not as a fragment of
293 + /// a longer one. `$8` must not match inside `$8,410`, and `2.9%` must not match
294 + /// inside `12.9%`.
295 + fn contains_bare(line: &str, needle: &str) -> bool {
296 + let bytes = line.as_bytes();
297 + let mut from = 0;
298 + while let Some(rel) = line[from..].find(needle) {
299 + let start = from + rel;
300 + let end = start + needle.len();
301 + let before_ok = start == 0 || !matches!(bytes[start - 1], b'0'..=b'9' | b'.' | b',' | b'$');
302 + let after_ok =
303 + end == bytes.len() || !matches!(bytes[end], b'0'..=b'9' | b'.' | b',' | b'%');
304 + if before_ok && after_ok {
305 + return true;
306 + }
307 + from = end;
308 + }
309 + false
310 + }
311 +
312 + /// `61960` → `61,960`. Leaves anything with a decimal point alone.
313 + fn group_thousands(digits: &str) -> String {
314 + if !digits.bytes().all(|b| b.is_ascii_digit()) {
315 + return digits.to_string();
316 + }
317 + let mut out = String::with_capacity(digits.len() + digits.len() / 3);
318 + let n = digits.len();
319 + for (i, c) in digits.chars().enumerate() {
320 + if i > 0 && (n - i).is_multiple_of(3) {
321 + out.push(',');
322 + }
323 + out.push(c);
324 + }
325 + out
326 + }
327 +
48 328 fn visit_markdown(dir: &std::path::Path, f: &mut impl FnMut(&std::path::Path, &str)) {
49 329 let Ok(entries) = std::fs::read_dir(dir) else {
50 330 return;
@@ -60,3 +340,44 @@
60 340 }
61 341 }
62 342 }
343 +
344 + mod lint_unit {
345 + use super::*;
346 +
347 + #[test]
348 + fn contains_bare_rejects_longer_numbers() {
349 + assert!(contains_bare("costs $580 a month", "$580"));
350 + assert!(!contains_bare("revenue of $5,800", "$580"));
351 + assert!(!contains_bare("total $5804", "$580"));
352 + assert!(contains_bare("fee is 2.9% today", "2.9%"));
353 + assert!(!contains_bare("fee is 12.9% today", "2.9%"));
354 + assert!(!contains_bare("$10,000/month", "10,000"));
355 + assert!(contains_bare("cap is 10,000 per list", "10,000"));
356 + }
357 +
358 + #[test]
359 + fn strip_markers_preserves_offsets_and_lines() {
360 + let input = "a {{ x.y }} b\nc {{ z }} d\n";
361 + let out = strip_markers(input);
362 + assert_eq!(out.len(), input.len());
363 + assert_eq!(out.lines().count(), input.lines().count());
364 + assert!(!out.contains("x.y"), "marker body must be blanked: {out:?}");
365 + assert!(out.starts_with("a "), "prose must survive: {out:?}");
366 + }
367 +
368 + #[test]
369 + fn strip_ranges_blanks_code_spans_only() {
370 + let input = "real $580 and `literal $580`";
371 + let out = strip_ranges(input, &code_span_ranges(input));
372 + assert!(contains_bare(&out, "$580"), "prose copy still visible");
373 + assert_eq!(out.matches("$580").count(), 1, "code copy blanked: {out:?}");
374 + }
375 +
376 + #[test]
377 + fn group_thousands_inserts_separators() {
378 + assert_eq!(group_thousands("61960"), "61,960");
379 + assert_eq!(group_thousands("580"), "580");
380 + assert_eq!(group_thousands("1000000"), "1,000,000");
381 + assert_eq!(group_thousands("0.31"), "0.31");
382 + }
383 + }
@@ -12,8 +12,7 @@
12 12 quotes = ["dep:regex-lite", "dep:uuid"]
13 13 frontmatter = ["dep:toml", "dep:tracing"]
14 14 media-urls = ["dep:regex-lite"]
15 - assumptions = ["dep:toml", "dep:regex-lite"]
16 - full = ["doc-loader", "directives", "mentions", "quotes", "frontmatter", "media-urls", "assumptions"]
15 + full = ["doc-loader", "directives", "mentions", "quotes", "frontmatter", "media-urls"]
17 16
18 17 [dependencies]
19 18 pulldown-cmark = "0.13"
@@ -29,10 +28,6 @@
29 28 tempfile = "3"
30 29 criterion = { version = "0.8", features = ["html_reports"] }
31 30
32 - [[bin]]
33 - name = "substitute_dir"
34 - required-features = ["assumptions"]
35 -
36 31 # Run with: cargo bench --features full
37 32 # (the render-path benchmarks exercise the doc-loader, directives, and quotes
38 33 # post-processors, all feature-gated.)
@@ -49,7 +49,6 @@
49 49 | `mentions` | regex-lite | `extract_mentions`, `resolve_mentions` -- `@username` parsing and linking |
50 50 | `quotes` | regex-lite, uuid | `post_process_quotes` -- replace `[quote:POST_ID:HASH]` markers with author attribution |
51 51 | `media-urls` | regex-lite | `rewrite_media_paths`, `img_to_video` -- CDN path rewriting and video tag conversion |
52 - | `assumptions` | toml, regex-lite | `Assumptions` -- load a TOML source-of-truth file, compute derived values, validate, substitute `{{ dotted.path \| filter(args) }}` markers in markdown with an extensible filter pipeline (built-ins: `int`, `ceil`, `floor`, `round`, `money`, `percent`, `upper`, `lower`) |
53 52 | `full` | all of the above | Enable everything |
54 53
55 54 ```toml
@@ -96,16 +95,21 @@
96 95 | `post_process_quotes(html, authors)` | `quotes` | Replace `[quote:UUID:HASH]` with clickable attribution |
97 96 | `rewrite_media_paths(md, base, user)` | `media-urls` | Rewrite relative image paths to absolute CDN URLs |
98 97 | `img_to_video(html)` | `media-urls` | Convert `<img>` tags pointing to video files into `<video>` elements |
99 - | `Assumptions::load(path)` / `::parse(text)` | `assumptions` | Load and parse a TOML assumptions file |
100 - | `Assumptions::validate()` | `assumptions` | Check internal consistency (sums, bounds, founding ≤ standard) |
101 - | `Assumptions::substitute(md)` | `assumptions` | Replace `{{ dotted.path \| filter(args) }}` placeholders with raw or derived values, optionally piped through filters |
102 - | `Assumptions::with_filter(name, impl Filter)` | `assumptions` | Register a custom filter for use in the substitution pipeline |
98 +
99 + ## Value substitution
100 +
101 + `{{ dotted.path | filter(args) }}` substitution used to be a docengine feature. It moved
102 + out on 2026-07-25 into two crates: [`subst`](../subst) (the generic engine) and
103 + [`mnw-assumptions`](../mnw-assumptions) (the MNW business-model layer on top). Nothing
104 + about the render path changed -- the server builds an `Assumptions` at boot and hands its
105 + `substitute` to `DocLoaderConfig::pre_process`, which is the same hook any other
106 + pre-render text transform would use.
103 107
104 108 ## Consumers
105 109
106 110 | Project | Features used | Preset |
107 111 |---------|--------------|--------|
108 - | MNW | `doc-loader`, `directives`, `frontmatter`, `media-urls`, `assumptions` | Permissive (docs/blog), Standard (descriptions) |
112 + | MNW | `doc-loader`, `directives`, `frontmatter`, `media-urls` | Permissive (docs/blog), Standard (descriptions) |
109 113 | Multithreaded | `mentions`, `quotes` | Strict (forum posts) |
110 114 | GoingsOn | core only | Standard (notes, descriptions) |
111 115 | Balanced Breakfast | core only | Sanitize-only (RSS feed content) |
@@ -107,6 +107,18 @@
107 107 monthly_credit_usd = 5
108 108
109 109
110 + # ─── SyncKit developer billing (canonical: src/synckit_billing.rs) ───────
111 + # The Rust constants are authoritative; this block mirrors them so the public
112 + # copy in site-docs/public/developer/synckit.md substitutes instead of
113 + # hardcoding. `tests/assumptions.rs` fails if the two drift.
114 + [synckit]
115 + storage_usd_per_gb_month = 0.03 # STORAGE_RATE_CENTS_PER_GB
116 + invoice_floor_usd = 0.31 # BASE_FLOOR_CENTS — smallest charge clearing Stripe's fee
117 + min_priced_gb = 1 # implied by validate_knobs (> 0)
118 + max_priced_gb = 10240 # MAX_STORAGE_GB (10 TiB)
119 + max_priced_display = "10 TB" # display form of max_priced_gb
120 +
121 +
110 122 # ─── Hetzner prices (canonical: hetzner_prices.md) ────────────────────────
111 123 [hetzner]
112 124 fx_eur_to_usd = 1.085 # Verify when FX moves >5%