# envelopes — llms.txt This file is for an AI assistant helping someone build a budget for https://envelopes.lukeroh.de. If you're an AI reading this because a person asked you to help them plan their money, this is everything you need. ## What this is envelopes takes its name from a real habit: my grandmother kept envelopes in her fridge, one per spending category, cash divided between them on payday. Groceries had its own envelope, bills had theirs, and when an envelope was empty, that category was done spending for the period — not a guess, not a vibe, just what's actually left. Envelope budgeting is that idea formalised: instead of one undifferentiated bank balance you're hoping covers everything, money gets allocated into named accounts (envelopes) as it arrives, and each envelope only ever holds what it's actually meant to cover. This tool runs that idea forward in time. Every account has an opening balance and, optionally, an interest rate and a floor (the minimum it should never drop below). Transfers move money between accounts on a schedule — weekly, fortnightly, monthly, yearly — the same rhythm real pay and bills follow, so the simulation matches how money actually moves through a household, not an average smoothed over a year. Two timeframes live in the same model: everyday cashflow (pay in, groceries/bills/card spending out, every week or fortnight) and the long retirement horizon (super contributions, drawdowns, a mortgage paid off over years) — because they're not actually separate problems, they're the same accounts and transfers, some firing weekly and some firing once a decade. Goals sit on top of all that: a goal watches for a date, an age, or a balance being reached, and can redirect money once it fires — stop the mortgage repayment, start funding retirement savings instead, the moment the house is paid off, say. That's how a real plan actually changes shape over a lifetime, modelled as data instead of hand-waved. ## Privacy guarantee - No account, no login, no signup. Nothing about the person you're helping is collected, because nothing is asked for. - No backend. This is a static site — HTML, CSS, and JavaScript served from a CDN. There's no server processing anyone's numbers; every calculation runs in their own browser. - Nothing is stored anywhere by default. Close the tab and it's gone. Keeping something is an explicit choice the person makes: Export downloads a plain YAML file to their own computer; Copy share link puts the whole config into the URL, compressed — never sent to any server, because a URL fragment (the part after `#`) is never included in the actual request a browser makes. - No analytics, no tracking, no cookies. - This app never calls you or any AI service itself, and never asks for an API key. You are here because the person brought you here, on their own terms. ## Two ways you'll arrive here **They have nothing yet** — you're building a plan from scratch. Go to "Starting from nothing", below. Interview them first; don't write YAML until you have. **They handed you a link** — something like `https://envelopes.lukeroh.de/#H4sIAAAA...`. That fragment *is* their plan, gzipped. Go to "You've been handed an existing plan". Read it, run it, and hand back a new link — don't start over, and don't reimplement the engine to read it. ## Getting the engine Whichever way you arrived, you can run the real projection instead of guessing at it. The whole engine is published as one self-contained JavaScript file — no clone, no package install, no Docker: curl -O https://envelopes.lukeroh.de/envelopes-cli.mjs # CLI, ~70KB node envelopes-cli.mjs my-draft.yml node envelopes-cli.mjs --json my-draft.yml # machine-readable node envelopes-cli.mjs --real my-draft.yml # in today's dollars node envelopes-cli.mjs --flows my-draft.yml # annual in/out/net per phase node envelopes-cli.mjs check my-draft.yml # what to fix, in order node envelopes-cli.mjs lint my-draft.yml # just the findings curl -O https://envelopes.lukeroh.de/envelopes.mjs # library, ~72KB **Prefer `--json`.** The text output is column-aligned for a human to read; parsing it means a change to the padding can break whatever you built. The JSON carries nominal *and* real balances together, so you don't have to decide up front which you wanted. **Nominal figures mislead over a 40-year run.** $1.66M of super in 2038 is about $1.16M in today's money. Before you tell someone a number that far out, look at the real one — `--real`, or the `real` field in the JSON. Zero dependencies, Node ≥18. `envelopes.mjs` exports: | export | what it does | |---|---| | `simulate(yamlText, opts)` | run a plan, get balances, milestones, history | | `report(yamlText, opts)` | the same text the CLI prints | | `reportJson(yamlText, opts)` | the same run as an object, nominal and real | | `deflate(amount, inflation, years)` | a future amount in today's dollars | | `lintPlan(yamlText, opts)` | named findings — what's wrong with this plan | | `checkPlan(budget, result, start, end)` | criteria, ordered findings, and the one next step | | `summarise(phases, budget, start, real)` | annual in/out/net per account, per phase | | `load(yamlText)` | parse and validate, without running | | `run(budget, start, end, track)` | the raw engine, if you already have a `Budget` | | `formatReport(...)` | format a finished run as text | | `decodeShareUrl(url)` | a share link → the YAML inside it | | `encodeShareUrl(yamlText)` | YAML → a share link to hand back | | `ageAt`, `addDays`, `todayISO` | date helpers the engine uses | The four share-link functions are `async`; everything else is synchronous. **This is a JavaScript path.** If your sandbox only runs Python, don't reimplement the engine — the day-by-day interest, offset and goal-cascade behaviour is not something to guess at. There's a Python decode recipe below for reading a link, but for *running* a plan, write the YAML and let the person paste it into the site, which does the same calculation in their browser. If you'd rather work from source, clone https://github.com/lukerohde/envelopes and run `npm install && npx tsx src/cli.ts my-draft.yml`. That repo's `Makefile` wraps everything in Docker, which your sandbox almost certainly doesn't have — use the npm commands directly, or the published bundle above. `AGENTS.md` in that repo is for a *coding* agent working on the app itself, not for building someone's budget. ## You've been handed an existing plan ### 1. Decode the link The plan is gzipped and base64url-encoded into the URL fragment. With the bundle: import { decodeShareUrl } from "./envelopes.mjs"; const yamlText = await decodeShareUrl(""); If you only have Python, this needs no packages: ```bash python3 -c " import base64, gzip, sys s = sys.argv[1].split('#',1)[1].replace('-','+').replace('_','/') open('plan.yml','wb').write(gzip.decompress(base64.b64decode(s + '='*(-len(s)%4)))) " "" ``` Either way you now have their actual plan as YAML. The rest of this file — the schema, the worked example — tells you how to read it. ### 2. Run it before you say anything about it node envelopes-cli.mjs plan.yml You now have real milestones and real closing balances. Say those, not impressions of the config. ### 3. Let the tool tell you what to fix first node envelopes-cli.mjs check plan.yml node envelopes-cli.mjs check --json plan.yml `check` is the one to reach for. It states what a good plan looks like, marks each part pass or fail, and ends with a single next instruction. Do that one thing, run it again, repeat. Don't try to fix everything at once — the levers are coupled and you'll chase your tail. **What a plan has to do**, which is the part no linter can infer: 1. **The cashflow holds.** No everyday account ever drops under its floor. This comes first and isn't negotiable: the run stops measuring at the first breach, so every other number is describing a plan that already stopped. `check` will say "not assessed" for the rest while this fails — believe it, and don't read those as passes. 2. **The money lasts well into their eighties.** Not to the end of the chart. The chart runs to 100 because that's the edge of a human life, not a target; inflation eats every retirement balance eventually and reaching the far edge takes either a fortune or spending nobody would accept. Where it runs out is the person's decision — tell them the age. 3. **The account that finally gives out is the retirement fund**, not the pay account. Super running dry at 84 is the plan reaching its end. A pay account running dry at 46 means the cashflow never worked. 4. **Nothing pools unspent.** A clearing account that climbs is income no envelope claimed, earning nothing. 5. **Sinking funds cycle, savings beat inflation, every goal fires.** **Spending is an input, not a knob.** If you balance a plan by cutting someone's groceries you have not balanced anything, you've just moved the problem somewhere they'll notice later. Ask them before you change what they told you they spend. Income, savings rates, retirement dates and drawdown sizes are yours to tune; their life is not. `lint` gives the same findings without the goal-setting, if that's all you want: node envelopes-cli.mjs lint plan.yml node envelopes-cli.mjs lint --json plan.yml Named findings, and it exits non-zero when there are any. Each one reads the same per-phase flows `--flows` prints, so you can always check a finding against the row it came from rather than taking it on faith. The rules: | rule | what it means | |---|---| | `account-below-floor` | the balance drops under the floor the account declares — the money isn't there on the day it's needed | | `clearing-account-accumulating` | income no envelope ever claimed, piling up | | `saving-below-inflation` | grows in dollars, shrinks in what it buys | | `sinking-fund-trending` | money in, none out, forever — the spending it saved for isn't modelled | | `goal-never-fires` | never reached, so everything downstream of it never happens | | `super-before-preservation-age` | super drawn before 60 | **`account-below-floor` is not always an error.** Read the date it gives you. Falling under the floor at 46, while someone is still working, is insolvency — income and outgoings don't line up and the plan is broken today. Falling under it at 85 is the plan running its course: their money lasts that long and no longer. Inflation eats every super balance eventually, and the only escape is a balance big enough to live on its real return, which most people don't have. **Don't "fix" that by cutting their spending to something they'd never accept.** Tell them the age, and let them decide whether to work longer, save more, or spend less. Nothing reported after that date is real, either — the simulation keeps paying bills out of money that isn't there, so the linter won't quote a balance from beyond the point a plan collapsed, and neither should you. A clean run means no rule tripped. It does **not** mean the plan is good — the linter has no idea what they want. Read the checklist below too. ### 4. Then review it yourself These are the failures that show up again and again, and most of them are invisible unless you go looking at the right account: - **An account ending negative.** Especially super or a bridge fund. This is the single most common serious error and it doesn't announce itself. - **A drawdown that doesn't match the spending it funds.** A drawdown larger than the spending quietly piles money up in a pay account while emptying the account that fed it; smaller, and the pay account bleeds out. You can only see either one on the account being drawn *from*. - **Escalation mismatches.** An escalating drawdown against fixed spending, or the reverse. Check that each drawdown and the spending it funds agree. - **Savings below inflation.** A `rate` under the config's `inflation` means the balance grows in dollars and shrinks in what it buys. - **A sinking fund that only trends up.** Money in, none out, forever — a holiday or Christmas fund with no spending modelled against it. - **A goal that never fires.** If it isn't in the milestones, its transfer overrides never happened, and everything downstream of it is wrong. - **Super drawn before preservation age.** In Australia that's 60 for anyone retiring now. A bridge fund that runs dry early is the usual cause. - **A transfer still running that nothing pays for any more.** Every transfer a goal starts keeps going until something stops it. Walk the plan forward and ask of each one: what is still funding this? ### 5. Hand back a new link An edit that only exists in your chat window is not a change to their plan. All four steps, or you've wasted their time: 1. Decode the link to `plan.yml`. 2. Edit with **minimal diffs**. Goals reference transfers and accounts by exact string, so names must stay byte-identical — rename an account and every goal pointing at it silently stops matching. 3. Re-run and show before/after milestones, so they can see what your change actually did. 4. Re-encode and give them the new link: ```js import { readFileSync } from "node:fs"; import { encodeShareUrl } from "./envelopes.mjs"; console.log(await encodeShareUrl(readFileSync("plan.yml", "utf-8"))); ``` If the resulting URL is too long to paste comfortably, give them the YAML instead and tell them to paste it into "Edit as YAML" on the site. ### 6. Iterate, don't speculate A 40-year projection runs in milliseconds, so test your ideas instead of reasoning about them. `simulate()` takes the YAML *text*, not a parsed object: ```js import { simulate } from './envelopes.mjs'; const s = simulate(yamlText, { years: 40, // default 40 start: '2026-08-12', // default today track: ['pay', 'early retirement'] // per-account daily timeseries }); s.completed // [["pay off the house","2032-11-05"], ...] s.balances // closing balances s.history // { "pay": [["2026-08-12", 3200], ...] } ~14,600 rows ``` **Variant diffing.** Copy the YAML, change one thing, re-run, diff `completed`. That's how you answer "what if we retired at $500k" — with two dates, not an opinion. **Sensitivity.** Loop over each transfer amount, bump it, record the milestone deltas, sort by effect size. A few dozen runs, still under a second, and it tells you which knobs actually matter before you start suggesting changes. ## Starting from nothing ### 1. Interview them first — properly Do not write any YAML until you have all of this. Ask one thing at a time, like a person would, not as a form to fill in. Most people have never been asked these questions and will need a bit of drawing out — "roughly what do you spend on groceries in a normal week?" beats "please state your weekly grocery expenditure." - **Who's in the household, and their dates of birth.** Ask for this early and don't skip it. Every age-based goal — retire at 55, access super at 60 — is impossible without a real date of birth, and it's the thing people least expect a budgeting tool to want, so they won't volunteer it. - **Every income**: who earns it, how much lands in the bank, and how often (weekly, fortnightly, monthly). Take-home pay, not gross — this models money that actually arrives. - **Regular spending**, broken into the categories they'd actually recognise: groceries, bills, transport, kids, whatever they name. Rough weekly or monthly numbers are fine. Perfect is not the point; the shape is. - **Debts**: mortgage, car loan, credit card. Balance, interest rate, repayment amount and how often. Ask whether the mortgage has an offset account against it, and what's in it — it changes the maths a lot and people often forget to mention it. - **Savings and retirement**: what's in each, what interest or return they expect, and what's going in regularly. In Australia this is superannuation; ask for the balance, the contribution, and the contributions tax coming back out. - **What they're actually aiming at.** This is the whole point and the easiest bit to skip past. Retire by a certain age? Pay the house off? Save a deposit, a car, a trip? Get specific: which account, what number, by when or by what age. Ask what changes when each one lands — that's what a goal's transfer overrides express, and it's the difference between a projection and a plan. - **One-offs worth modelling**: a car in four years, school fees starting in 2029, an inheritance. `every: once` exists for exactly this. Ask follow-ups when something doesn't add up. If their income minus their spending doesn't match what they say they're saving, say so and work out where the difference went — that gap is usually the most useful thing in the conversation. ### 2. Write a YAML config matching the schema below. ### 3. Check it, then hand it over Run it first. A config that looks right and quietly sends an account to minus forty thousand dollars is not a helpful thing to give someone — see "Getting the engine" above, and the review checklist, which applies just as much to a plan you wrote yourself as to one you were handed. Then tell them: "Paste this into 'Edit as YAML' at https://envelopes.lukeroh.de — click the toggle near the top of the page, paste, and it'll build the rest of the page from what you gave it." If you can run JavaScript, you can also hand them a share link directly, with `encodeShareUrl` from the bundle. **Use that function; don't hand-roll it.** Gzipping and base64url-encoding a fragment yourself is easy to get subtly wrong, and a link that decodes to nothing is worse than no link at all. If you can't run the bundle, plain YAML text is the robust choice — they can make their own link with one click. ## The schema Top level, all required unless noted: ```yaml inflation: 0.03 # annual rate, applied to anything that "escalates" (below) birthdays: - {name: alex, born: 1980-05-20} # name + ISO date, one entry per person accounts: - {name: alex pay, balance: 3200, floor: 800} # see Accounts below transfers: - {name: alex salary, amount: 3650, every: fortnight, day: 2026-08-07, into: alex pay} # see Transfers below goals: - name: pay off the house account: mortgage target: 0 transfers: [...] # see Goals below ``` ### Accounts ```yaml - name: alex pay # unique, referenced by transfers/goals by this exact string balance: 3200 # opening balance, defaults to 0 floor: 800 # minimum this account should hold, defaults to 0 kind: clearing # see the table below; defaults to `expense` rate: 0.04 # annual interest rate, defaults to 0 offsets: mortgage # another account's name, whose accrued interest # this account's balance reduces (an offset # account). Not valid on `expense` or `loan` ``` `kind` says what an account is *for*. Pick it carefully — it's what makes a plan checkable, because each kind carries something that should be true of it: | kind | for | should be true | |---|---|---| | `expense` | groceries, bills, a credit card | pure pass-through; the closing balance is cumulative spend, not money anyone has | | `clearing` | pay, an account everything is paid from | shouldn't trend upward — if it does, that's surplus going nowhere in particular | | `sinking` | car, holidays, Christmas | fills and empties on a cycle; roughly flat in real terms across a full one | | `saving` | a house deposit, a bridge fund | accumulates towards a target, then drains once | | `investment` | super, shares | `rate` should beat `inflation`; should never go negative | | `loan` | mortgage, car loan | pays down towards zero | `expense` is the default and the old name `everyday` still loads as one, so older configs keep working. But a pay account written as `everyday` will be read as a spending envelope — if you're editing an existing plan, setting `kind: clearing` on the account income lands in is usually the single most useful correction you can make. `kind: loan` is the only one the simulation itself reads: money arriving at a loan pays it down instead of adding up. The rest are there so a plan can be checked against what it claims to be. ### Transfers Move a fixed or escalating amount between two accounts on a schedule. ```yaml - name: alex salary # unique, referenced by goal overrides by this name amount: 3650 every: fortnight # once | week | fortnight | month | year day: 2026-08-07 # see below — depends on `every` out_of: alex pay # source account; omit for external income into: alex pay # destination account; omit if it just leaves escalation: 0 # omit (defaults to growing with `inflation` # each year) or set to 0 for a fixed nominal # amount that never grows (a mortgage # repayment set by the loan contract, say) ``` `day`'s meaning depends on `every` — this trips people up, get it right: - `every: once` → `day` is the date it happens (`2030-03-14`), and it happens on that day and no other. For a car, school fees starting, an inheritance, a lump-sum payment. Still escalates with inflation unless you set `escalation: 0`, which is usually what you want: a $40,000 car in 2030 costs more than $40,000 in 2030. - `every: week` → `day` is a weekday name: `mon`, `tue`, `wed`, `thu`, `fri`, `sat`, `sun`. - `every: fortnight` → `day` is a real anchor date (`2026-08-07`), not a weekday. A weekday alone can't say *which* of the two alternating weeks — fires every 14 days counting from that date. - `every: month` → `day` is a day-of-month number (`1`–`31`), clamped to the month's actual last day if it doesn't have that many days. - `every: year` → `day` is `month-day` (`12-25`), same clamping. A transfer with no `out_of` is external income arriving (a salary). A transfer with no `into` is money leaving the system entirely (rare — most spending still lands in some account, even a pure pass-through one). ### Goals A goal watches for something to happen, then can redirect transfers and/or account interest rates once it fires. ```yaml - name: pay off the house account: mortgage # which account this goal watches target: 0 # balance-trigger: fires when `account`'s balance reaches # this (below, for a debt paying down; above, for a # savings target) # OR, instead of account+target, a date trigger: # by: 2030-12-12 # OR an age trigger: # by_age: {person: alex, turns: 55} # `person` matches a name in `birthdays` transfers: - {name: mortgage repayment, amount: 0} # stop an existing transfer by naming # it and zeroing its amount - {name: mortgage freed-up saving, amount: 2850, every: month, day: 5, out_of: alex pay, into: early retirement, escalation: 0} # a goal's transfer override can also *introduce a brand new transfer name* # that doesn't exist anywhere else — this is how "stop this, start that # instead" is expressed. A later goal can reference that same new name again # to change it further (redirect it again, stop it too) — each override only # needs to state the fields that changed from whatever it was right before. accounts: - {name: super alex, rate: 0.06} # optionally also change an account's interest # rate once this goal fires ``` A goal's `account`/`target` fields are only read for a balance trigger — if you use `by` or `by_age` instead, they're ignored, unless you ask for both: ```yaml - name: super access by_age: {person: alex, turns: 60} account: bridge fund target: 0 wait_for_both: true # fire on the LATER of the two, not the first ``` **This is the most important guard an early-retirement plan has.** Without it you can't say "the bridge fund is empty *and* they're 60" — only one or the other. Get it wrong in either direction and it's expensive: fire on the age alone and you stop the bridge drawdown with money still in it while starting super early; fire on the balance alone and you draw super before it's legally available. Two things to know: - **Direction is inferred from the account's *opening* balance.** If the target is above where the account started, "reached" means rising to it; below, falling to it. So a fund that starts at `0`, fills up, and drains again cannot express "back to empty" with `target: 0` — that reads as "at or above 0", which is true on day one. Give the account a real opening balance, or target a number it starts above. - A balance goal snaps its account exactly to the target when it fires, to absorb the overshoot from checking once a day. With `wait_for_both` that only happens if the balance crossed on the same day the goal fired — if it crossed years ago and the goal was waiting on a birthday, the balance is left alone rather than rewritten. ## Worked example One person aiming to retire at 55 — illustrative, not anyone's real numbers, but a genuine example of everything above working together: an income, an offset mortgage, week-to-week envelopes, super, and four goals that hand off to each other in sequence. The income is a $180k package: about $4,924 a fortnight after tax and Medicare, and $1,466 a month landing in super after the 15% contributions tax. That's the shape to copy — model take-home pay and net contributions, not gross figures, because gross money never arrives anywhere. It works because the pieces are sequenced. The house is paid off at 52 while still earning; the repayment then becomes retirement saving; that builds a bridge fund which carries the years between retiring at 55 and super unlocking; super then runs to 83. Take any one of those away and the plan doesn't reach. Three things in it are worth copying into whatever you write for someone else. **The order matters as much as the numbers.** The house is paid off at 52, while still working, and only then does that repayment become retirement saving. Get that ordering wrong — pay the house off *after* retiring — and the config quietly redirects money that isn't coming in any more, draining a super balance for decades with no error and no warning. **Escalation is a pairing, not a preference.** Almost everything in the example escalates with inflation — the salary, the spending, and both drawdowns — so they stay matched as the years pass. The exception is the mortgage repayment, fixed at `escalation: 0` because the loan contract fixes it. Get that pairing wrong and the plan fails slowly. A salary held flat against spending that grows at 3% drains the pay account over a working life, for no reason you can see on any single account. What matters is that each drawdown and the spending it funds agree. An escalating drawdown against fixed spending starves the pay account for years and then floods it while the account it draws from empties; a fixed drawdown against escalating spending drains the pay account instead. Both failures are invisible on the accounts you're looking at — you only see them on the account being drawn down, so go and look. **A drawdown is sized by the spending, not chosen.** Add up what actually leaves the pay account in a phase — weekly groceries, fortnightly bills, monthly everything else — and set the drawdown to that. Guess it low and the pay account bleeds out years before the fund it's protecting is empty; guess it high and money piles up in pay while the fund drains early. `--flows` gives you those subtotals annualised, so you can read the number off instead of doing the arithmetic. ```yaml inflation: 0.03 birthdays: - name: Alex born: '1980-05-20' accounts: - name: pay balance: 12000 floor: 800 kind: clearing rate: 0 offsets: null - name: groceries balance: 0 floor: 0 kind: expense rate: 0 offsets: null - name: bills balance: 0 floor: 0 kind: expense rate: 0 offsets: null - name: everyday balance: 0 floor: 0 kind: expense rate: 0 offsets: null - name: car balance: 0 floor: 0 kind: expense rate: 0 offsets: null - name: travel balance: 6000 floor: 0 kind: sinking rate: 0 offsets: null - name: mortgage balance: 320000 floor: 0 kind: loan rate: 0.061 offsets: null - name: early retirement balance: 5000 floor: 0 kind: saving rate: 0.04 offsets: null - name: super balance: 320000 floor: 0 kind: investment rate: 0.07 offsets: null transfers: - name: salary amount: 4924 every: fortnight day: '2026-08-07' into: pay - name: groceries amount: 150 every: week day: sat out_of: pay into: groceries - name: bills amount: 300 every: fortnight day: '2026-08-07' out_of: pay into: bills - name: everyday spending amount: 150 every: week day: sat out_of: pay into: everyday - name: car costs amount: 200 every: week day: sat out_of: pay into: car - name: mortgage repayment amount: 5180 every: month day: 5 out_of: pay into: mortgage escalation: 0 - name: into the offset amount: 2000 every: month day: 20 out_of: pay into: early retirement - name: saving for a holiday amount: 500 every: month day: 5 out_of: pay into: travel escalation: 0 - name: the holiday amount: 6000 every: year day: 12-20 out_of: travel escalation: 0 - name: super contribution amount: 1466 every: month day: 15 into: super goals: - name: pay off the house account: mortgage target: 0 transfers: - name: mortgage repayment amount: 0 escalation: 0 - name: mortgage freed-up saving amount: 10000 every: month day: 5 out_of: pay into: early retirement escalation: 0 - name: into the offset amount: 1200 every: month day: 5 out_of: pay into: early retirement accounts: [] - name: retire at 55 account: early retirement target: 0 transfers: - name: salary amount: 0 escalation: 0 - name: mortgage freed-up saving amount: 0 escalation: 0 - name: into the offset amount: 0 - name: super contribution amount: 0 - name: bridge drawdown amount: 1900 every: fortnight day: '2026-08-07' out_of: early retirement into: pay - name: car costs amount: 400 every: week day: sat out_of: pay into: car accounts: [] by_age: person: Alex turns: 55 - name: super takes over account: early retirement target: 0 wait_for_both: true transfers: - name: bridge drawdown amount: 0 escalation: 0 - name: super drawdown amount: 2500 every: fortnight day: '2026-08-07' out_of: super into: pay - name: car costs amount: 500 every: week day: sat out_of: pay into: car - name: groceries amount: 300 every: week day: sat out_of: pay into: groceries - name: bills amount: 400 every: fortnight day: '2026-08-07' out_of: pay into: bills - name: saving for a holiday amount: 900 every: month day: 5 out_of: pay into: travel accounts: [] by_age: person: Alex turns: 60 ``` Notice "super takes over" uses `wait_for_both`. It watches the bridge fund, not super, and fires only once that fund is drained *and* alex is 60 — which here lands at 62, because the fund outlasts the birthday. Fire on the birthday alone and the plan would stop the bridge drawdown with two years of money still sitting in it. It's also *not* called "super access at 60", deliberately: a goal named after a date it doesn't actually fire on is a lie you'll believe later. Notice "the holiday": `travel` is a `sinking` fund, so money goes in monthly and comes out once a year. Without that outflow it would just grow forever, which is the most common thing wrong with a hand-written plan. Notice "bridge drawdown" — introduced fresh inside "retire at 55"'s own transfer overrides, not in the top-level `transfers:` list at all. "super takes over" then references that same name again to stop it. That's the override-cascading pattern described above, working in a real example. Notice too that "retire at 55" explicitly zeroes "mortgage freed-up saving". Every transfer a goal starts keeps running until something stops it, so when you write a plan, walk it forward and ask of each transfer: what is still paying for this, and is that thing still there? A saving transfer funded by a salary that has stopped will happily drain a retirement account for forty years. And check the size of each drawdown against what's actually being spent. A drawdown larger than the spending it funds doesn't look wrong on a chart — it just quietly piles money up in a pay account while emptying the retirement account that fed it. **This example runs out at 85, and that is not a bug.** `lint` reports one finding against it — `account-below-floor` on the pay account in 2065 — and that finding is the answer, not an error: the money lasts to 85 and no longer. Inflation eats every super balance eventually. The only escape is a balance so large you can live on its real return, which most people don't have and can't get to by tidying a config. So don't chase a plan that survives to the end of the chart. Get someone comfortably into their eighties and then tell them the age, plainly. Whether they'd rather work two more years, save more now, or accept spending less later is their decision and nobody else's — and it is a much more useful conversation than a projection that pretends the problem isn't there. What the example *doesn't* have is any of the other findings, and that's the bit worth copying. Balancing it took real work: the salary had to escalate alongside the spending it funds, the super drawdown had to be sized to what's actually being spent rather than picked, and the whole thing had to hold up over the full run rather than on average. Expect to iterate the same way. A plan that balances first try usually means something isn't modelled yet.