Skip to content

Decisions

Choices that look wrong until you know why. Each one cost something to learn, and each is the kind of thing a reasonable person would "fix" without this page.

Code points here by number rather than repeating the reasoning inline — see style.md. Add to the end; do not renumber.

Shape of the app

1. Everything is computed from trades. No stored balance, no cached valuation. Correcting a trade from three years ago corrects every number, because none of them were written down. Do not add a column holding something derivable; if it is slow, use the fingerprinted cache in queries.py. → architecture.md

2. Tenancy fails closed. Scoped queries are filtered by an event hook in tenancy.py, and a scoped query with no portfolio bound raises. A new table holding personal data goes in SCOPED_MODELS; that is the whole registration. A filter each query must remember is one that will eventually be forgotten.

3. Auth refusals live in auth.load_session. Expiry, the idle window, the absolute cap, the half-authenticated 2FA state — one choke point, not per route, for the same reason as #2.

4. Optional features remove their routes, not just their links. An install that switches a feature off is not carrying an unlinked door to it (features.py).

Money and truth

5. Never fabricate a number. A figure that cannot be computed is blank, not guessed. The rule has three parts: use the nearest stored FX rate within a tracked pair; withhold and name the holding when the pair is unknown; never fall back to 1:1, which books a foreign holding as though the currency did not exist. totals.excluded, dividends_excluded and the FY report's withheld are how the withholding gets said out loud. A sale of more units than were held then is refused, never worked around: every way of writing a trade refuses one, and a ledger that holds one from before those checks has no capital gains for the years that depend on it. Gains without that holding would not be the year's gains, so the FY page and the API name the sale for somebody to fix (CgtSummary.oversold), and the CGT exports refuse until it is.

6. …but a same-currency row needs no rate at all. AUD → AUD is 1 by arithmetic, so requiring a stored fx_rate on an AUD row turns a fact about completeness into a question with no content. One distribution imported without a rate voided its instrument's entire dividend history, and with most holdings affected the dashboard's dividend total read as a fraction of the truth — silently, because the number still looked like a number. queries.in_aud() is the one place that distinction lives. This is not #5 bending: there is no conversion to invent.

7. Rounded parts must sum to the whole. CGT proceeds and cost base are apportioned by running-total differencing, not by rounding each parcel independently — otherwise three parcels out of $14.00 report $14.01, and a schedule that does not tie out is worse than one that is a cent coarse. fyreport._Parcel.take.

8. A DRP residual is a balance, not income. The whole distribution is already assessable through cash_amount. Never add residual_carried to a dividend total, and note that a DRP parcel's cost base already includes whatever residual was applied to it.

9. Only ticker symbols leave the machine. No quantities, no holdings, no telemetry. Statement parsing and OCR are local subprocesses with no network. This is the app's headline promise and it constrains every new integration.

Market data

10. Ask an exchange only for sessions it has finished. "What day is it here" is a different question from "what has that market finished doing" — at the 18:00 Sydney feed it is 04:00 in New York, so asking for today's US close requests a session that has not opened. pricefeed.MARKETS holds each exchange's timezone and closing time; SETTLE adds 30 minutes because the bell is not the same as a settled price.

11. Public holidays are deliberately not modelled. A per-market calendar has to be maintained forever, and being wrong costs one empty fetch. Weekends roll back; holidays do not. The same reasoning sets GAP_WEEKDAYS = 3: a market shuts for one weekday, or two at Christmas and Easter, never three — so a shorter threshold would make every holiday a permanent "gap" the feed re-asks about on every run.

12. Live prices are stored beside closes, flagged provisional. The app needs both meanings of "price": what a holding is worth now, and what the day closed at. Storing only the first corrupts history — a mid-session snapshot recorded as a close, which happened, permanently, to 99 of 359 closes.

13. …and the daily run resumes from the last SETTLED day. This is what makes #12 safe rather than a relabelled version of the same bug: the run comes back for the provisional day and replaces it. Without it, one missed evening leaves a snapshot on the books looking like a close for ever.

14. Crypto never settles, on purpose. It has no close, so its bar is only final at 00:00 UTC and waiting would make the holding always show yesterday. A fresh number is preferred. Crypto rows stay snapshots; do not "fix" this without asking.

15. yfinance for closes, plain JSON for quotes. Not inconsistency: a failed quote costs nothing (the page shows the last close), while a failed close feed makes the history wrong. yfinance's real value is being maintained against Yahoo's anti-bot measures, so it stays where failure matters. Quotes use the batched spark endpoint — one request for the whole portfolio.

16. The "Live" badge is built from holdings, not from stored rows. It speaks for the portfolio on the page. Built from the instrument table it was lit around the clock by a zero-unit BTC row — a security nobody owned.

Operational

17. Heavy imports are deferred, and that buys less than it looks. yfinance (~101 MiB of pandas and numpy) and pdfplumber import on first use. But the feed's catch-up runs seconds after boot, so an install with the feed on pays it anyway and keeps it for life. The laziness protects the feed-disabled install, the CLI and the test suite. Do not quote it as the reason the pod is small.

18. Measure memory after the first feed run, never at startup. A 63 MiB start-up reading is what set the request to 128Mi when the real working set was 182 MiB.

19. Background polls must not slide the session window. Anything the page calls on a timer belongs in auth.SLIDING_EXEMPT, or a tab left open keeps its own session alive for ever and the idle timeout is decorative.

20. versions/ starts from one generated 0001_initial, and every change after it is a revision of its own. 0001 was generated from models.py when the history was squashed, and a revision is never edited once it has shipped: a change to the models gets a new one. The drift test (test_the_models_and_the_migration_have_not_drifted) migrates to head and compares the result with the models, which is what makes that safe.

21. The API's published/withheld list is the contract. Every stored column is either published with the field carrying it, or withheld with a reason, in tests/test_api_parity.py. Adding a column fails the suite until somebody decides which. The reasons are the documentation — the only kind that cannot go stale.

22. Scratch files live in /scratch, never in the data directory. The statement mapper renders page images of a document somebody is mapping — their name, address and holder number — kept 0700 and deleted 30 minutes after last use. /data is wrong for them twice over: it is the directory the deploy docs tell you to back up, so a retention policy would keep exactly what the page promises to throw away, and a backup mover that drops capabilities loses DAC_OVERRIDE and cannot traverse a 0700 directory it does not own. /scratch exists in the image, so this needs no volume; mount an emptyDir for a size limit. Losing a mapping session on restart is intended. A broker CSV's parsed rows wait there too, between the preview and the commit (imports.staging_dir), swept after a day.

23. user.public_id exists ahead of need, and must never be reissued. An auto-increment id cannot be an OIDC sub: it leaks how many accounts exist and when this one was made, and every app's "user 1" collides. Adding it later means backfilling across every federated app at once. Reissuing one would silently unlink the account everywhere it had ever signed in.

24. Some schema declarations look redundant and are not. index=True without unique=True, alongside a named constraint in __table_args__, produces a plain index plus a named UNIQUE — not one unique index. Same guarantee, different catalogue entries, and the difference is exactly what a schema comparison reports. Applies to user.public_id and webauthn_credential.credential_id.

25. A sortable header may cycle through four states, not two. Gain and Today show an amount with its percentage underneath, and both are worth ordering by: a $2 gain on $10 beats a $200 gain on $100,000, and "what made the most" and "what grew fastest" are different questions. The header cycles value ↑, % ↑, value ↓, % ↓ rather than growing a second control. The state must be rendered, not just explained — after two clicks nobody can tell which of four they are in. 26. The CGT module leaves out what it cannot know, rather than guessing. Until the 2027 rules were built (#150), every year from 1 July 2027 was refused: a repealed discount applied to an FY2028 disposal would have been a confident wrong number on a page people use to prepare a tax return. Now a sale is left out of the year's totals, listed and named, when the rules need something the app does not have: the CPI for a quarter not published yet, or a close in the week before 1 July 2027 to value a parcel at. That is how a sale with no exchange rate is treated (#5), for the same reason. fyreport.NEW_RULES is the boundary.

27. The navigation holds places, not controls. A control belongs beside the thing it acts on, not in a list of destinations repeated on every page — so Charts, Record trade and Manage holdings live on the Portfolio page, and Admin is a cog in the top bar because it is app-level rather than a destination. What is left is the three things that are genuinely places.

28. A split chart of a ratio is grouped, never stacked. Ratios do not add up: splitting a +20% / +40% asset class by ticker drew a stack 0.60 tall where the unsplit bar of the same holdings is 0.30 — twice the height, same data, and a number nobody had computed. Grouped rather than refused, because "each ticker's gain % within its class, side by side" is a real question and the segments are individually correct; only putting them end to end invented a total. charts.js groups when stacked is false.

29. Three paths are public to the middleware but not to the world. /login/code runs while the session is deliberately half-authenticated, and refuses without a live awaiting_totp session. /session/status must answer the idle overlay rather than return a 401 it has to interpret, and discloses only whether the cookie already presented is still live. /metrics has no cookie to present; it is off unless enabled and 404s when off, and that check is the whole access control — read metrics.py before adding a series.

30. Sixteen recovery codes, not eight and not a passphrase. They stand in for a forgotten password as well as a missing second factor, and an authenticator-less sign-in burns one every time until the person re-enrols, so the list drains faster than eight allows for. Sixteen single-use codes beat one long secret: a spent one is worthless to a shoulder-surfer and the list survives being partly used. A 12/24-word phrase is the wrong shape here — those are offline-derived master keys held by their owner; these are server-issued, stored hashed, and revocable one at a time. 31. Many columns carry both default= and server_default=, on purpose. default= is Python-side and covers rows this app inserts; server_default= is DDL and covers everything else, including a migration adding a NOT NULL column to a populated table. The migrations declared them and the models did not, which is invisible in normal use and poisonous in one way: autogenerate diffs the models against the database, so each looked like a default to drop — an autogenerated migration would have removed eighteen of them. Keep the pairs in step; the drift test compares them.

32. The periods chart grain cannot be split, and that is not a limitation. The grain is a fixed set of performance windows, and a window has no sub-parts to break out. Before the rule existed a split here was accepted and then silently dropped — charts_build._periods ignores split — so the chart drawn was not the chart asked for, with nothing saying so. The builder reaches it by clicking the Period chip twice.

33. app_name is defaulted so the app boots with no config file. A first-run wizard cannot ask you anything if the process will not start without the answers, and "create config.yaml before starting" is the instruction people skip. Every other setting already had a default, so this one makes the whole file optional and the wizard writes it. Changing it moves the database: appcore derives the SQLite path from app_name, so a rename points a running install at a file that does not exist and it will cheerfully create an empty one. Set database.path explicitly if you ever change it.

34. Two different things both sounded like "admin", so the labels name the scope. user.is_admin is app-wide — accounts and settings. member.role == "owner" is rights over one portfolio — its settings and its people. A site admin gets nothing in a portfolio beyond the role held there: settings went to the site admin first, so an owner could not rename their own portfolio while an admin who only viewed it could. Collapsing them to one word would make them look identical while behaving differently, so the interface says "Site admin" and "Portfolio admin". The stored values stay owner/member/viewer.

35. Annualise on the window asked for, not the span it snapped to. start is the first price date on or after the cutoff, so a weekday-only feed lands on a weekend about two days in seven and the span comes to 363–364 days. The window is still the year the row is labelled, and annualising a 364-day one moves it by ~0.1%. The arithmetic still uses the real span. "All time" passes no window and is judged on its actual span: it is only a year old when it is a year old.

Imports, and the documents they read

36. A recovery code substitutes for the password, never for the second factor. There is no reset email: a self-hosted app has no mailbox it can rely on, and a reset link is only as strong as the inbox it lands in. Somebody holding only the code list therefore gets as far as choosing a new password and is then stopped by the 2FA challenge — because a session that has already spent a code cannot spend another (UserSession.recovery_spent). That single rule is what stops a stolen list from being the whole account.

37. The document never leaves the machine, and inference is testable. The statement designer and the broker mapper both work on text already extracted in-process and posted back with the form — nothing is uploaded anywhere and nothing is stored, because a broker export is a list of everything somebody owns and when they bought it. Inference lives in docformats.py and brokerdesign.py rather than in a route or in JavaScript: a wrong label produces a template that reads the wrong number on somebody else's statement, which is the failure the whole feature exists to avoid. What a template exports describes where to look, never what was found.

38. Templates can be installed through the interface only because they are inert. Declarative YAML, loaded with YAML(typ="safe"), whose patterns never reach re.compile — so a hostile file is a parsing problem, not code execution. Without this route, using a template meant putting a file on the container's disk by hand, which on Kubernetes means a read-only ConfigMap. If templates ever gain an executable escape hatch, this route goes with it.

39. Two-factor enrolment is two requests, and the pending secret is held on the user row. Showing a secret and then confirming it with a code derived from it is what stops somebody enabling 2FA with a secret they never successfully scanned and locking themselves out.

It used to ride in the form so that an abandoned enrolment left nothing. That was the wrong trade: the secret was regenerated on every render, so refreshing the page — or coming back to it after a mistyped code, which the wizard does on its own — silently invalidated the QR just scanned, and the phone's code stopped matching with nothing on screen to explain it. Found in sundries and present here twice, on the profile page and in the wizard.

So an enrolment in progress is resumed: the secret is stored, and returning shows the same QR. Storing it enables nothing, because is_enabled wants totp_enabled_at as well — an unfinished enrolment is inert and signs nobody in. It also closed something the form version allowed: any secret posted with a matching code used to be accepted, so a caller could enrol an authenticator the account had never scanned.

40. A back button reads ?return=, never Referer or history.back(). The holding page is reached from the dashboard, an FY page, Manage holdings and the DCA schedule, so a fixed "← Portfolio" was wrong three times in four. The header and the history stack are guesses that vanish on a refresh or a shared link, and the button would then be labelled for a page it does not go to. The rule the charts page set: it says where it goes.

41. The database connection is deliberately conditional. A container with empty appdata and no configuration has nothing to connect to, and the first-run wizard cannot ask if importing the module already failed trying. An unconfigured install boots with database.is_ready() false, serves the wizard, and connects once somebody has chosen. When one is configured, migrations run here before any traffic is served.

42. Recovery codes are 15 characters, and the throttle stays regardless. NIST SP 800-63B wants a look-up secret to carry 64 bits, or 20 if failed attempts are throttled. At log2(31) = 4.95 bits a character, 13 clears 64; 15 is used because it groups into three blocks of five, which is the difference between a code transcribed correctly and one that is not — 74 bits. The throttle is not removed when the arithmetic stops needing it: it turns a wrong guess into one wrong guess rather than the first of millions. 43. A stale login form gets the login page back, not a 403. Nobody is signed in without a valid token, so this changes only how a refusal is presented. A cross-site POST carries no SameSite=Lax cookie, has nothing to echo, lands in the same branch and gets a login page — the same outcome the 403 produced, without accusing somebody whose page was simply open too long.

44. Two session limits, because they stop different things. session_idle_minutes is the sliding one — an hour, not days, because this is a finance app left open on shared machines. session_absolute_days is a hard cap from creation that the sliding renewal cannot extend; without it a session used daily never expires, and a stolen cookie stays valid forever as long as it keeps being used.

45. One recovery code per sign-in. Otherwise somebody holding the list spends one for the password and a second for the second factor, and the list alone is the whole account. NIST SP 800-63B is the reasoning: a code is a look-up secret, one authenticator of type "something you have", and two factors must be of different types — password + code counts, code + code does not. UserSession.recovery_spent enforces it.

46. jurisdiction, not country, and the label may differ from the column. It decides which tax rules apply — the financial-year boundary, the CGT rules, and the words the reports use. It follows tax residency rather than an address, and the two genuinely differ for some people. The Admin page still says "Country" because that is the word people recognise: the user-facing term and the column name are allowed to differ, and the code is where the distinction has to be unambiguous.

47. NULL in user.display_currency means "the portfolio's". A default of "AUD" would be a claim that somebody chose it, and would silently pin the wrong currency for the first person running this outside Australia. Same shape as any preference column: absent is not a value.

48. fx_rate is captured at the trade's date and never re-derived. Re-deriving history from today's rates changes last year's tax return. NULL means not captured, and the price feed can backfill it. The name says reporting-currency-per-native rather than AUD so it does not contradict portfolio.reporting_currency the day somebody sets that to something else.

49. Two-factor is a setup step again, but an opted-into one. It was removed in 2026-08-08 as a second door to a room /profile/2fa already opened, and the cost named at the time is the one that arrived: a step is a prompt and a toggle is not. Nobody enrolled. It is a step again — offered only when the account step's tickbox asks for it, so the run that does not want it is not lengthened, and setupwizard.skipped() keeps the rail and the router agreeing about whether it exists. The line about doing it later moved to that tickbox's tooltip, where the question is actually being asked.

50. "Avg price (AUD)" is not cost_aud / units. cost is buys only and includes brokerage, and units is net of sales, so that quotient is a third number — and it would sit beside a column headed "Avg price" in the native currency that means something else. Two columns of the same name must differ only by the exchange rate, so it is computed on the same basis as the native one: buys and DRP allocations, no brokerage, each converted at its own rate.

51. A spent recovery code is refused indistinguishably from a wrong one. Saying "that code was real, but you may not use it here" would confirm to somebody who stole the list that they hold a valid one. See #45 for the rule being enforced.

52. Two columns sort by something other than what they display. ticker hands the renderer the whole holding — a link plus a currency badge — and Holding objects do not compare. drp renders "yes" or nothing, and nothing sorts as blank, which would push every non-reinvesting holding to the bottom in both directions and make one of the two clicks appear to do nothing. Everything else sorts by what it shows.

Testing

53. The suite must not reach the network, and one route out is not obvious. Adding an instrument calls main._kick_feed(), which under TestClient finds a running loop and schedules a real feed run on a worker thread — importing yfinance, calling out, then running a full gc.collect(). That thread outlives the test that started it, so the failure does not look like a test failure: the suite passes while background threads make live calls, and the operating system logs crash reports from GC traversing pandas on a stack that is not the main one. conftest._no_network blocks that, providers._get_json, and yfinance itself (pricefeed._yf). The last is easy to miss: yfinance's HTTP client is C and never touches Python's socket module, and pricefeed.lookup reads any failure as "not found", so an unstubbed lookup made a real call and passed. The stand-in fails the test outright, which lookup cannot catch. tools/screenshot.py, which the visual tests run as a separate process, is outside conftest's reach, so it stubs _kick_feed itself and asserts at the end that yfinance was never imported — it started a real feed run on every visual pass until it did.

54. Two measures of gain, each labelled for what it is. A holding's gain is unrealised: its value less what the units still held cost, the oldest counted as sold first (fyreport.walk), each parcel at its own rate. That is the cost base the CGT export uses, so a reinvested unit counts at what was reinvested rather than as free; the distribution is in the Dividends column. The holdings table, the tiles above it, a holding's page, the FY snapshot and the holdings export all measure this. The portfolio's Performance chart and the period figures are total return: value, plus sale proceeds and cash income, less everything put in. Once anything is sold or paid out the two differ by design: buy 100 at $10, receive $100, sell 50 at $20 with the price still $20, and the holding has made $500 on what it still holds while the portfolio has returned $1,100 on $1,000. Measured against every buy instead, the old Gain tile said $0 there, which is neither.

The line is cumulative return over money in, not a time-weighted return. "Don't count new investments as a gain" first read as TWR, which removes contributions by chaining daily returns — it compounds, and on a long dollar-cost-average history it showed 125% where the portfolio had made 47%. Arithmetically fine, and not the number anybody means.

55. An HTTPS-configured instance reached over plain HTTP says so. This is the nastiest failure in the app and it is silent: with cookie_secure on, a browser on plain HTTP discards the session cookie. The password is accepted, Set-Cookie is sent, the browser throws it away, and the person lands back on the login page with nothing to explain it — so they try the password again. No second factor helps; the cookie cannot be stored however you authenticated. The only useful response is to say so before they try.

56. Sorting must not be able to move a figure. It reorders the lists totals are computed from. That it cannot change one is true — Decimal addition is exact and commutative, and the FY report totals before it reorders — but true is not the same as pinned, and this is the rule with the least tolerance for a regression. tests/test_sorting.py holds the pins.

57. One definition of "did this template read the right values". test_format_samples.py owns that comparison and everything else imports it. Two implementations would eventually disagree, and the wrong one would be the copy. It matters more than it sounds: an expected row lists the fields worth pinning, not every field a template returns, so a strict dict comparison fails on a perfectly good read.

58. back is a convenience that must not become an open redirect. The column chooser posts the page's current query back so saving does not throw away the sort you were reading under — which makes it a form field, attacker-supplied and headed for a Location header. _safe_query and _safe_path are the guards. Found untested by mutation: deleting the validation broke nothing.

59. A scan has no text layer, so the mapper falls back to OCR. extract_words reads the text layer, and the first version of the statement mapper therefore rendered a page with nothing clickable on precisely the documents it was built for. Found by running a real scanned PDF through it, not by any test above it.

60. The setup rail leaves out steps that do not apply, and done beats skipped. An install with its database and config supplied should not be looking at nine steps it will never visit. _skipped_steps() asks whether a database is configured, and after the database step there is one — so without this rule a step just completed is reclassified as "not needed" and vanishes from the rail. Completing something is the strongest fact about it. 61. python -m app.recover clears the second factor and forces a password change. It is the last resort — for somebody who has lost both their password and their recovery codes — so leaving 2FA on would hand back an account they still cannot reach, having spent the only tool left (--keep-2fa covers the narrower case). must_change_password is set because the ordinary use is an administrator recovering somebody else's account, which ends with the administrator knowing that password and the second factor gone. One extra form closes it, and self-recovery pays the cheaper of the two mistakes.

62. Dashboard columns render in the order they were chosen. Declaration order was deliberate once — a table whose columns move between sessions is harder to read — but that argument is about incidental movement, and an order somebody picked is stable by definition. Unknown keys are dropped silently, because a stored preference outlives the column it names and a saved choice must never be what stops the dashboard rendering; duplicates collapse for the same reason. A locked column keeps its position — locked means it cannot be turned off, not that it must come first.

Boundaries

63. Tenancy is a boundary inside the app, not against the database. tenancy.py stops a bug in a route or query serving one person's portfolio to another. It is no defence against anyone holding the file: the importer CLI, sqlite3 /data/stocktake.db, a copy of the PVC or a restic snapshot all read every account, and no login check could change that — it would run in the process that already has the file. The boundary is "can reach the volume", not "has an account". Real separation needs per-user encryption keyed on their password, which would also lock out the price feed, the FY reports and the backups. 64. Formats are data because nobody can test a format they have no document for. Whoever maintains this holds accounts at almost none of these registries, so a parser per document is code the merger cannot verify. A template plus a redacted sample inverts that: the person with the statement authors it, and CI checks it forever on hardware that has never seen the real document.

65. The setup draft lives in memory, and nothing is written until it works. One uvicorn worker means no second process to disagree; a restart mid-wizard should lose a draft nothing was written from; and persisting it would mean writing setup state into the database the wizard has not chosen yet. The database is probed before it is connected and connected before it reaches config.yaml, which is written once, at the end — so an abandoned wizard leaves no file, which is what makes starting again safe.

66. Recovery codes come before two-factor enrolment. They are what makes skipping the next step survivable. The usual reason to put them after is that they are a by-product of enrolling — but here they are also the whole of password reset, so they are issued whether or not the next step runs and cannot belong to it. Ordering them first also closes the window where a second factor is live and nothing can recover it. Issued once and re-shown, never reissued: see #111.

67. A provider falls back on error or empty, never on "fewer rows than asked for". Three days when five were requested is normal — markets close. Treating it as failure flaps between sources and rewrites the same rows with a different source every run.

68. Equities have no fallback provider, deliberately. Stooq answers with a JavaScript proof-of-work challenge and publishes no API, so using it would mean defeating an anti-bot measure. Alpha Vantage does not document ASX coverage; Twelve Data lists ASX only on a paid add-on. A provider whose coverage of the actual holdings cannot be verified is worse than none — it fails silently while source claims the numbers came from somewhere real. The seam works: PROVIDERS["equity"] is a list, and adding one is a function plus a config entry.

69. Redaction masks what has a mechanical shape and says plainly what it cannot. Long digit runs, TFNs, emails, phone numbers and money are masked; a name and a street address are just words, and no pattern separates "MR JOHN SMITH" from "ACME REGISTRY PTY LIMITED". The result is shown in an editable box before anything leaves the machine. A redaction that misses a line is worse than none, because somebody trusting it publishes their address.

70. Redacted amounts are shifted, not zeroed. Zeroing makes every expected value 0.00, so the file whose job is proving the template read the right fields cannot tell net_amount from franking_credits. Each figure keeps its shape — digit count, grouping, decimals — with different digits derived from the original, so the same amount appearing twice stays the same amount.

71. Account recovery is a command, not documented SQL. Setting a password by hand means generating an argon2id hash by hand, and pasting a plaintext string produces an account that can never log in and an error explaining nothing. The command runs the same hashing, validation and session invalidation the web UI does. It is not a backdoor: it needs shell access to the machine holding the database, and anyone with that can already read every holding, session hash and TOTP secret (#63). 72. The portfolio is the tenant, not the person. Several people can contribute to one (portfolio_member, roles owner/member/viewer), so trades, dividends, plan history and per-holding notes carry portfolio_id, and that is what tenancy.py filters on. user_id survives as who recorded it — an audit trail once a portfolio has several contributors — and must never be filtered on. Market data (instrument, price, fx_rate) is deliberately shared: one person adding ALPHA gives everyone its price history, and nothing about a holding is inferable from a ticker existing.

73. Rendered statement pages are never held between requests. They go to disk and are served from there, so a pod that has run the visual mapper once is not carrying bitmaps until its next restart. The session token is random and the directory records who created it, so another signed-in user presenting the token gets nothing. Thirty idle minutes ends a session, and ending it deletes the files rather than refusing to serve them — swept on the next upload, and by the daily maintenance job for the install where nobody uses the feature again.

74. The restart button says what the deployment can actually do. Kubernetes replaces a stopped container immediately. Docker only restarts when the container was created with a restart policy, and nothing inside can see whether it was. A bare uvicorn will not come back at all, and an unqualified "Restart" there would be offering to take the application down. So supervision() reports which of the three it is and the page words the button accordingly.

75. "Configured but failed" is a different state from "not configured". The answer to the first is "fix this" and to the second "choose one". Silently falling back to an empty SQLite file would be the worst response available: it looks like it worked and the data is gone. A database: heading with nothing under it is the first kind: it is what commenting out the two shipped lines leaves, on an install that has data, so the app refuses to start and names the line rather than opening the public wizard (db.start).

76. The OFX reader never hands an upload to a general XML parser. External entity expansion and entity-expansion denial of service both live in Python's stdlib parsers unless deliberately disabled. A reader that understands only tags and text has no entity machinery to abuse.

77. The CGT discount is applied per parcel in the export, and after losses in the FY report. The ATO allows the 50% discount on a parcel held over twelve months, and applies capital LOSSES before it. The export shows the per-parcel figure because it is the working; the FY report nets losses first because that is the number to lodge. Both are right for their job, so the caveat travels inside the exported file rather than living only in the interface.

The losses include earlier years'. A year's net capital loss carries forward and is applied in the next year with gains, after that year's own losses and before the discount, each in the taxpayer-favourable order (s102-5(1)): fyreport.fy_cgt works out every year from the first sale. Without it a 300 loss one year left the next year's 300 discountable gain at 150 rather than nil, on the number this page calls the one to lodge. The app knows only the sales recorded in it, so a loss used or made elsewhere is not in the figure. A year it cannot work out, for a sale of more than was held, leaves the loss it would have carried unknown, so every year after it names that sale too.

78. config.yaml is edited in the app, under three constraints. It may not be writable — mounted from a ConfigMap or baked into an image — which is a deployment choice, not a fault, so the page shows every value and explains why it cannot save rather than failing when tried. Comments must survive, because the shipped file documents every option and a plain YAML dump would strip it on the first save. And database and app_name are read-only: changing where the data lives, or the file's own name, from a web form is a way to lose a database.

79. Five things the second factor gets right that are commonly got wrong. The secret is stored as issued, in the clear — encrypting it needs a key in the process that reads it, and anyone who can read that column can already read every holding (#63). The QR is rendered locally, because the obvious shortcut sends the secret to a third party in a URL. One step of clock drift is accepted: stricter generates support requests, looser widens replay for no gain. Recovery codes are hashed like session tokens and marked used rather than deleted, so a replay is distinguishable from a code never issued. And no TOTP code is accepted twice (RFC 6238 §5.2; GHSA-28h6-x798-2qv5, from freeb5d): the step of the last accepted code is kept on the user and only a later one passes. The rule is the condition on a single UPDATE rather than a read then a write, so two requests racing with one code cannot both get in. The step is cleared with the secret.

80. A currency symbol is part of the value, not decoration. Templates once wrote a literal $ before {{ x | money }}, which produced $-4.00 and left the symbol outside the privacy blur, sharp beside a smudge. Headline numbers carry the symbol because no column header can carry it for them; table columns carry it in the header, because a $ repeated down two hundred right-aligned cells is noise that fights the alignment making the column scannable; a column that can hold more than one currency names it per row.

81. Which column a table is sorted by lives in the URL. It survives a reload, can be linked and bookmarked, and two people looking at one portfolio do not fight over it — the honest scope, since sorting is something you do while reading, unlike which columns exist. Sorting is server-side because the values are Decimals and some are None: in the browser "1,234.50" sorts as text and an em dash sorts wherever the browser feels like. Blanks sort last in both directions, since reverse=True would otherwise put the rows with no data on top.

82. The timezone is process-wide configuration, not an argument. Threading it through queries, fyreport, plans and calendarview would put a parameter on two dozen functions with nothing else to say about it, and every signature would be a chance to forget. Set once at startup from settings.timezone.

83. The price feed commits per instrument, not once at the end. SQLite takes a single writer and this job is mostly network latency, so one transaction spanning every fetch starves page loads until they fail on busy_timeout.

84. OCR is local or it does not happen. A dividend statement carries a name, an address, a holder number and an amount. That rules out every hosted OCR API and every "just send it to a model" shortcut, and leaves Tesseract — a local binary with no network of its own. Measured, not estimated: it adds 107 MB to the image, 37 MB of that libicu via leptonica and 15 MB language data. That cost was weighed and accepted; the alternative was the promise that only ticker symbols leave.

85. The metrics endpoint is off by default and has no client library. Every other route needs a session, so an upgrade that quietly began answering an anonymous caller would be a surprise — the answer being harmless is not the same as the change being expected. Six series in a fixed format is also less code than wiring up a registry, and the memory budget is the reason not to pull in a library that costs more than it saves.

86. The date format is guessed from the values, not the header. %d/%m/%Y against %m/%d/%Y cannot be told apart by a column name, and the wrong choice is silently correct for eleven rows in twelve — only the first twelve days of a month disagree, which is the worst kind of wrong. dates_are_ambiguous() exists so the interface can say when the samples genuinely cannot decide, rather than guessing more confidently.

87. Switching a feature off hides it; it never deletes anything. The nav entry, the calendar and the editor go, and the route says so rather than 404 — a 404 for a page that exists and is switched off is a lie. The plan, its rotation and every planned_purchase row stay, so turning it back on restores what was there. Somebody switches a feature off to tidy the nav, not to throw away two years of schedule.

88. The API is append-only. The web UI can edit and delete a trade; the API cannot, and there is no PATCH or DELETE to find. A key is a long-lived credential held by another program, and the blast radius of a loop with a bug in it is very different for "wrote a duplicate trade" than for "rewrote the last three years". Corrections are a human sitting in front of the ledger. One setting is the exception, the marginal tax rate (#147): it is not a record.

89. The theme designer exposes six colours, not every token. Exposing all of them lets somebody set their page background to the colour of their text. The six are the ones carrying meaning rather than structure — the accent, the two directions money can go, and the three chart series — so a bad choice is ugly, never unusable. Each mode has its own six, because a colour legible on white rarely is on near-black: one value for both had to be held to 3:1, since no colour is 4.5:1 against both cards (the best is 3.9:1). Per mode, each is measured on its own card against the floor for what it is drawn as, 4.5:1 for the accent, gain and loss, which are text, and 3:1 for a chart's lines. Advice, not a veto. The built-in colours meet the same floors, which is why dark loss is #f25050 and not the 4.31:1 #ef4444. The page's colours are a <style> with a rule per mode rather than an inline style, which cannot follow the device on auto; the profile page opens the tab for the mode in use, and the other is editable without switching.

90. The in-app log is WARNING and above, in memory, and never written to disk. Error-only says something broke and nothing about what led there; INFO is every HTTP request, which buries the line that matters. The band between carries a feed falling back, a statement that parsed with fields missing, a job that skipped. It stays in memory because these lines can quote a ticker or a filename, and a log file is one more thing to reason about when the promise is that the data stays in the deployment. A restart clears it, so the real logs remain the source of truth for anything historical. 91. The ledger is not append-only, which is why trade.updated_at exists. The series cache fingerprints on it; without that an edit changes no row count and every chart goes on serving pre-edit numbers. A DRP trade is not editable directly — its units and its dividend's cash are one statement event, so it is edited through the dividend or not at all. The same holds for every table the series reads that is rewritten in place: prices (a second quote, or the settled close replacing a provisional one, #13), FX rates, instruments and holding settings carry updated_at too. The feed's upserts set it themselves, because onupdate does not fire on an insert that becomes an update.

92. Every /setup route opens with _wizard_step(). The prefix is public, because the early steps run before there is a database to hold a session, so the login middleware cannot gate them. A route under /setup without that call is one anybody can post to.

93. Sale proceeds are apportioned across parcels by running-total differencing. Per-unit-then-round gives each parcel its own rounding error: three one-unit parcels out of 14.00 each came to 4.6666…, rounded to 4.67, and the schedule reported 14.01 of proceeds against 14.00 of money. Carrying the total forward and taking differences makes the parts sum to the whole by construction, and the leftover cent lands on a parcel instead of on nobody.

94. The pre-auth CSRF cookie outlives the session idle window by a wide margin. If it lapses first, the login page somebody was just sent to fails its own double-submit check, with a message that reads as their fault. Twelve hours: long enough that a login page left open overnight still works, short enough to stay a bounded credential-free token.

95. Two returns are reported, never one. on_money_in is growth over what was at work — the familiar figure, and the one that stays low while somebody is still buying in. twr chains each day's return with contributions removed, so it says how the investments performed rather than how much was fed in; it runs higher for a portfolio built by regular buying, because the early money has compounded for years. Quoting either alone invites the wrong conclusion. A simple "gain / invested" across two windows divides by different denominators, so a 5-year and an all-time figure are not comparable.

96. rp_id is declared in config and never read off a request header. Host and X-Forwarded-Host are client-controlled, and the rp_id is the anchor every WebAuthn credential is permanently bound to — taking it from a request would let a caller choose which domain their credential counts for.

97. The CSP carries unsafe-inline, and that is stated rather than hidden. The templates still carry inline blocks and onclick/onchange handlers, so dropping it breaks the pages. With it present the CSP is not the XSS backstop it looks like: the real defence is Jinja's autoescaping plus |tojson for data blocks. Extracting those fragments is what earns the strict version.

98. The OCR end-to-end document is generated at test time, not committed. It is drawn from the redacted sample already shipped for the generic-au template and saved with no text layer. A committed PDF cannot be read in a diff, carries metadata and embedded fonts nobody reviews, and raises a licensing question about somebody else's document in an AGPL repository. Generating it also tests the layout that actually ships, since the sample is the layout.

99. A broker import creates instruments it does not know, and asks first. Refusing was the old default, and it makes the first import of an install impossible: every ticker in a broker's own export is unknown at that point, so the answer was a config edit before anything could be imported at all. What stops a typo becoming an instrument is the preview's resolve step — the new tickers grouped by instrument, each with its exchange, editable, and rechecked against the database on demand. That also settles the case a flag cannot: one ticker listed on two exchanges, where the file is right and the exchange is what needs correcting. annotate() is re-entrant for this reason; a stale "unknown-instrument" would keep refusing a ticker that now resolves.

100. A DRP allotment with no price is refused, not booked at zero. Some exports list the allotment as a row with units and no price — the registry bought them out of the distribution, and the cost base is in the statement rather than the export. Importing it at zero understates that parcel's cost base and overstates the gain when it is sold, which is a tax figure. The row is skipped with the reason, and the dividend statement is the path that carries the number.

101. The broker mapper is drag OR tap, and the selects stay the state. HTML drag-and-drop does not fire on touch at all, and the layout is checked at 412px, so a drag-only mapper is one that does not work on a phone. Dragging a chip, tapping a chip then a heading, and choosing from the list all do the same thing: set a named <select> and fire change. That is also what makes the page work with JavaScript off — the list is what a plain browser gets, and the trays reveal themselves only once the script runs. The charts page follows the same rule: a card moves by its earlier and later buttons as well as by drag, in place with the script and through the form without it.

102. Words the app already understands are shown, not flagged. Buy and Sell need no mapping, so outlining them beside a genuinely unknown word says the whole column needs attention when two rows do. They render greyed with their resolved type; only a word nothing can read is outlined.

103. Clearing a mapping is its own control, and tapping a chip re-arms it. A tap that both assigns and un-assigns is a mapping lost to a stray touch, and long-press is invisible, has no keyboard equivalent and fights the operating system's own context menu. So an assigned chip carries an explicit ×, and tapping the chip itself arms it — which is also how a field MOVES to another column without being cleared first.

104. A column is claimed by at most one field. Two fields reading the same column parses without complaint and produces trades whose price is their brokerage, so taking a column takes it from whoever had it. guess() already worked this way; assigning by hand now does too.

105. The import pages carry the origin in a hidden field, not a query string. They are reached by POSTing a file, so there is no ?return= to read. The value goes through the same navigation.safe_path guard as every other back link, because it ends up in an href — //evil.test and /\\evil.test both look like paths and neither is one. safe_path and BACK_LABELS live in navigation rather than main so the imports router can reach them: a guard that is copied is one that will eventually be copied slightly wrong.

106. The Unraid template maps ONE folder and sets a restart policy. /data and /config are a real distinction on Kubernetes — a writable volume and a read-only ConfigMap — and no distinction at all on Unraid, where both were the same appdata directory. Mapping it twice asked one question twice and let somebody answer it two different ways, with the database and the config ending up in different folders and a backup of "the appdata folder" missing one. APP_CONFIG_FILE points inside /data instead.

dockerMan also creates containers with no restart policy, and lifecycle restarts by STOPPING and relying on the runtime — so the setup wizard's final step stopped the app and it stayed stopped. supervision() says so on the page, honestly, but an honest warning is not a working restart. Both shipped compose files already set unless-stopped; the template was the only target that did not. unless-stopped rather than always, so a stop from the Unraid UI is respected.

107. The app refuses every page while a wizard is in progress. The database and the account exist from step 3 onward — the account has to be written somewhere — so from there a session exists and every route answers. Deleting /setup from the address bar then walked into a half-configured app: no timezone, no portfolio, no config file written. Deferring the database until the end is not the alternative it looks like; step 3 needs somewhere to put the account. Setup is finished when the draft is discarded, so that is the signal. The redirect goes to the step the wizard is ON, not to /setup — welcome closes the moment an account exists, and sending somebody to a closed step bounces them to /login, which bounces them to /, which arrives back here. /logout stays open, because abandoning a wizard is a legitimate thing to want.

108. The "no database" check runs BEFORE the public-path list, not after. /login is public and opens a session to look for the account, so an unconfigured app answered it with NotConfigured — a 500 on the one page every redirect lands on, which reads as an app broken beyond recovery rather than one asking to be finished. Only static files, the health probes, the wizard and /metrics answer with no database at all; everything else, /login included, is sent to the wizard.

109. next_step derives from STEPS; a back button never offers a closed step. Two halves of one loop. next_step read from its own hardcoded list, which still carried "2fa" after that stopped being a step (#49) — harmless while nothing turned the answer into a URL, and a 404 the moment something did. It reads STEP_KEYS now, so it cannot name a page that does not exist. And welcome, database and account shut once an account exists: offering one as a back target sends you to /login, which sends a signed-in user to /, which sends a wizard in progress back to the wizard. Three redirects that each look reasonable. The recovery step therefore offers no way back at all, which is the answer rather than an omission.

110. A <details> cannot live in a <p>. It is block-level to the HTML parser whatever its CSS display says, so the paragraph is closed before it and the (?) lands on its own line — the tip's parent came back as <section>. Use .tipline for running text that carries one. And vertical-align: middle aligns an inline-block with the baseline plus half the X-HEIGHT rather than the optical centre, so a circle taller than the x-height sits low: measured 2.9px on a 1.15rem (?) beside 1rem text, corrected in em so it scales.

111. The codes step re-shows the same codes rather than minting new ones. It regenerated on every view, which is silent invalidation — write them down, press Back from the next step to check a character, and the list in your hand is dead with nothing on screen saying so. They are issued once per wizard and held in the draft; a fresh set is a deliberate act from the account page. Found by rendering the two-factor step and following its own back link.

112. Finishing setup restarts, without asking. It was a ticked checkbox, which made "Finish setup" able to mean "finish setup and ignore the settings I just chose" — the restart is what picks them up. It is now stated rather than offered, and only appears when there is something to pick up.

113. A passkey signs in on its own, with no password and no code. The ceremony is run with user verification REQUIRED, so the authenticator proves both possession of the device and a fingerprint, face or PIN before it will sign anything. That is already two factors, and stopping to ask for a TOTP code would be asking for a third — while a password user is asked for two. The requirement is enforced on the assertion, not merely requested in the options: an authenticator that answers with User Present alone is refused, because without it the assertion would prove only that somebody was holding the phone.

114. Signing in with a passkey asks for no email. The ceremony is discoverable — the authenticator names the account — so nothing is typed and nothing is confirmed. Sending allowCredentials would mean naming an account's keys before anyone had proved anything, which answers "does this address have an account here" to whoever asks.

115. The WebAuthn challenge lives in a row, not a cookie. It is stored hashed and deleted as it is read, exactly like a session token: a ceremony is two requests, and the nonce between them is what stops a captured response being replayed. A challenge that survived being used would make the signature worth nothing.

116. /invite/ is public, and the token is the whole authorisation. The person opening an invitation has no account yet, so the login middleware has to let them through — and every route under the prefix checks the token itself. Signing up is possible ONLY with a live one: without that check the endpoint is open registration on an app holding somebody's holdings.

117. An invitation never changes an existing membership. Accepting one when already a member consumes the invite and leaves the role alone, so a viewer link sent to an owner cannot quietly demote them. It is still consumed, because it was offered and answered — a spent link that still worked would be a live credential nobody thinks they have.

118. Invitations are not an OIDC feature. They were built for it — a provider can only auto-provision somebody who is authorised to exist — but membership needed them anyway: adding a member required the account to exist first, so there was no way to bring in somebody who had never signed in. An install with no identity provider gets the same link.

119. An OIDC identity is matched on (issuer, sub), never on email. An address is something a directory can often be told, so matching on it would let anybody who can set an email in the provider sign in as an existing local account. Provisioning refuses a colliding address rather than adopting it: two people are not one person because a directory says so. Linking an existing account to a provider is a deliberate act by somebody already signed in.

120. user.password_hash is nullable, and NULL is not a password. An account provisioned by a provider never had one. verify_password already returned False for a NULL hash — that was written for the account-does-not-exist case and turns out to be exactly right here — and unlinking the last identity is refused while the hash is NULL, because an account with no way in is not something a settings page should be able to create quietly.

121. A public client is asked for, not deduced from a missing secret. auth.oidc.client_auth is basic or none, and it defaults to basic. Reading the client type off an absent client_secret would be less to configure, but it makes a credential going missing indistinguishable from a deployment choice — the token request quietly stops authenticating and nothing says so. DatabaseSettings._guard_ambiguous_sqlite refuses the same shape for the same reason: an unset field selecting a different mode. So only the exact value none waives the secret — a typo still requires one, and a basic client with nothing set refuses rather than sending an empty password.

The asymmetry is deliberate: none with a secret still in the file ignores it rather than refusing. That is what moving a confidential client to a public one looks like halfway through, and refusing would strand a deployment between two working states.

122. appcore/ names no application configuration key. Its modules are written to be lifted into applications that have not shipped yet, so an error telling somebody to set auth.oidc.client_secret is wrong the moment it is used by an app that calls the setting something else — and wrong in the way nothing catches, because the sentence still reads fine. The protocol layer names the condition; the caller names the settings that fix it, because only the caller knows what they are called. Raised by review on #25, where the same misconfiguration had two messages that disagreed about how many ways out there were.

123. Removing the last member offers to delete the portfolio. They are one decision, so they are one action. A portfolio is reachable only through membership — load_auth builds the list from portfolio_member and there is no admin override — so removing the only member would leave one holding every trade and visible to nobody, admins included. It cannot be restored from the outside, only by re-adding a member nobody can see.

Offered to the owner, not gated on admin. The only person who can be in this position is the sole member of a portfolio nobody else can reach, so requiring an administrator adds no protection — there is nobody else to protect — and would leave a non-admin unable to tidy up their own, with no one able to help them. The confirmation and the tickbox are the guard rails.

124. A price of zero is a real trade, and the form asks for the trade's date. Zero was refused everywhere except the importers, which have always accepted it — so a bonus issue or an employer's grant could be imported but not typed. There is nothing in the database to relax: quantity > 0 is a CHECK constraint, unit_price never had one. Every percentage already guards its divisor (gain / cost if cost else None, percentage_applies), so a zero cost base reports N/A rather than dividing — which is the right answer, not a gap.

The second half is the reason this is here. The instrument picker used to carry each instrument's latest close and rate, and choosing one wrote them into the form. That is right only for a trade recorded today; on a backdated one it books the wrong cost base, and the wrong FX rate permanently — the price feed's repair pass fills fx_rate IS NULL and skips everything else, so a prefilled wrong rate is the one thing nothing will ever correct. Both figures now come from /holdings/price for the date on the form, and the option data was deleted rather than fixed: a field with two sources eventually takes the stale one again.

Where nothing is stored for that date the fields are left EMPTY, and queries.close_on_or_before reaches backwards only. This is deliberately narrower than FxBook.rate, which reaches forward to the earliest stored rate when a report asks about a date before the series — a report must produce a figure or omit the holding, whereas a form can ask the person to read their contract note. It is #5 applied to an input rather than an output: an empty rate is repairable, a plausible wrong one is not.

How far is a week (FX_REACH_DAYS). With the series fetched from backfill_start, the reach forward was years: a 2018 purchase took a January 2020 rate as its cost base, and nothing said so. So the feed fetches each currency's rates from a week before its first trade or distribution, and the reach is a weekend or a holiday. The repair pass and the form's prefill take a rate only from the week before, since what they store is never re-derived; and a report that still converts at a rate more than a week from the day names the holding (FxBook.far), on the FY page and in the API's distant_rates.

Zero is still not the right cost base for an employee share scheme, which is what prompted the change (issue #35). The ATO resets it to market value at the taxing point, and the taxing point also restarts the 12-month discount clock. That belongs in the guide, not in a validator — the app takes the number it is given.

125. A move between portfolios is one column, and the hard part is deciding what goes with it. Reported (#36) on the assumption that the destination needs its own instrument row and the trade details copied onto it. It does not: Instrument is a shared catalogue, unique on (exchange, ticker) and deliberately not portfolio-scoped, so the move is portfolio_id = target and nothing is copied.

Three rules decide what travels, and only two of them are enforced.

Balance pulls rows in. Take a buy out and a sell left behind can exceed what is still held, and balance_breach reports only the FIRST such point — so the set is a fixpoint, not a query. It can only ever add a sell: _walk drives the running total down on sells alone, buys and DRPs add to it, and quantity > 0 is a CHECK constraint. That is why one coupling pass is enough, which is otherwise a very easy thing to get wrong in the safe direction.

Pairs are inseparable. A reinvested distribution is one event across trade and dividend, joined by reinvest_trade_id, and every reader assumes the halves share a portfolio. So the dialog offers a pair as ONE row — the drp trade, which carries the units — and the coupling is applied server-side in both directions anyway, because the posted ids are user input and must not be able to split it. This is also why the move does NOT go through _trade_for_edit: that refuses a DRP trade, correctly, because editing half a pair leaves the units and the cash disagreeing. Moving both halves does not.

Cash is offered and never assumed. A distribution does not touch the unit balance, so nothing forces it — but a portfolio that no longer holds the units has no business holding the income either, so it is on the list. Which DRP buy "belongs" to which portfolio is likewise not derivable: a DRP arose from units held at a record date, and once those units are being split the attribution is a judgement. A person decides; the app enforces only what would otherwise leave the data inconsistent.

The pre-tick is a default, not a decision. expand(pull_in=True) fills the dialog's tickboxes with the rows that cannot be left behind; the submitted form uses pull_in=False, because re-running the pull-in there would silently restore anything somebody unticked and make the page do the opposite of what it was told. Unticking is honoured and then REFUSED, by source_problem, naming the sell that would be left short. Pairs are coupled either way: unticking a balance dependent is a judgement somebody may make and be refused for, while splitting a reinvested distribution is not a judgement but a shape the data cannot hold.

Neither side is ever auto-corrected. A sell moved somewhere holding nothing goes negative there, and the fix is to tick more buys — a choice, so it refuses and names the trade. Reading the destination at all needs tenancy.as_portfolio, which is narrower than unscoped_session on purpose and checks the membership itself: a primitive that rebinds the filter on request is a cross-tenant read waiting for one careless call site, so the authorisation lives with the scope change rather than in whichever template decided to draw a button.

A financial year already lodged is explicitly NOT a reason to refuse. Moving a trade can change a prior year's realised gain through FIFO re-ordering, and that was considered and rejected as a guard: the app computes from the data it holds, a return asks the person filing it to attest to what they supply, and a tool cannot un-lodge anything. See the disclaimer.

A PlannedPurchase whose trade leaves goes back to planned, unlike the delete path which keeps it at "done" because a deleted trade still happened. A moved trade did not happen here, so the slot is genuinely unfilled and the schedule should offer it again.

126. The FX leg asks only for days that have finished. A live pod logged $USDAUD=X: possibly delisted; no price data found (1d 2026-09-24 -> 2026-09-25) on every startup. Nothing was delisted and nothing was missing: the series was current to the 23rd and the feed was asking for the 24th, a day that had not closed. Yahoo has no answer for an unfinished day and says so in the only vocabulary it has.

The price loop has never done this — it stops at settled_through(exchange), the last date that cannot still move. The FX loop ran to today, so the first run of any day asked for that day.

fx_settled_through is separate from settled_through rather than reusing its fallback, and the reason is the weekend. That fallback answers today - 1 for a market with no known bell, which on a Monday is a Sunday — a day with no rate at all, producing the same error for a different reason. So it rolls back over the weekend the way last_close_date does for a market it knows.

Worth fixing for the log rather than the data: the stored series is unchanged, because a rate for an unfinished day was never going to arrive. But "possibly delisted" at ERROR is indistinguishable at a glance from a pair the app has genuinely lost, and a log that cries wolf daily is one nobody reads on the day it is right.

127. A back-channel logout ends sessions and nothing else. Issue #28. A provider that ends a session POSTs a signed logout_token; appcore verifies it and federation.end_sessions_for acts on it.

It never sets is_active = False, and that is the whole shape of the feature rather than a missing half. Letting a provider disable an account here is a far larger authority than ending a session, and it would mean a provider outage could lock everybody out of their own records. So somebody holding both a password and a provider identity is signed out and signs straight back in locally. It is session propagation, not revocation — and the app already has revocation, which is stronger and needs no provider: disabling a user calls destroy_sessions_for() and load_auth deletes an inactive user's session on their next request.

Keyed on sid, falling back to sub. sid ends exactly the session the provider named; sub ends every session that identity holds, which is more than it asked for and all that can be done with a token carrying only a subject. The sub path matches on (issuer, subject) and never on subject alone — subjects are unique only within a provider, and an installation that changed provider keeps the old external_identity rows.

That sid needs storing is what crossed a boundary. oidc.Identity's docstring says claims beyond its five stay unread, so session_id was added there deliberately: sid is not a claim about the person but an identifier for the provider's own session, and the boundary exists to refuse authorisation decisions taken from claims — groups mapped to roles — which this is not.

The endpoint is unauthenticated, and that is the specification. No cookie, no CSRF token, no API key: the signature on the token is what proves the caller is the provider. That is also what lets a public client use it, which is how the reporter's deployment is configured. Two checks stop an ID token being accepted as a logout instruction — a required events member and a forbidden nonce — because an ID token carries the same signature, issuer and audience and is the one an attacker is likeliest to have seen.

jti is required and not remembered: replaying an ID token would be a sign-in, whereas replaying a logout token ends a session that is already ended.

The sid lookup deliberately does not filter on via_oidc. oidc_sid is written in exactly one place and always alongside it, so a local session holds NULL and cannot match; the clause was there first, was unreachable, and a guard no test can fail implies a threat that does not exist. The sub path does filter on it, and that one is load-bearing.

128. An API key is its creator's, and does no more than they can now. GHSA-cjcx-g69x-63jq, from freeb5d. Deactivating someone or removing them from a portfolio ended their sessions and left their keys working. The reporter asked which model was meant: keys owned by the portfolio, or by the person who made them. The person: they saw the raw key when it was issued, so a key that outlives their access is their access, kept. Calling it the portfolio's does not change who holds the secret.

So keys live on the profile page, not the Members page, and anyone with access can make one — a viewer's key reads what the viewer can already read and export. A key reaches the portfolios chosen when it is made, picked from the creator's own (api_key_portfolio). One given more than one is told which on every request with ?portfolio=<id>; /api/v1/portfolios lists them. Whether that parameter is required depends on how many the key was GIVEN, not how many it can still reach, so a client's contract does not change shape because somebody's membership did. An id the key was not given is a 404, answered the same as one that does not exist.

Checked on every use (auth.key_reach, auth.api_session), not done by revoking keys when someone leaves. Revoking would have to be remembered at every way access ends (deactivation, removal, demotion to viewer) and would miss the next one added. A creator who is deactivated or gone makes the key a 401 everywhere; one who left a portfolio makes it a 403 there and nowhere else; one who became a viewer makes a write key read only there. It runs the other way too: restore the access and the key works again, because nothing was revoked. The profile page says, per portfolio, where a key has stopped or been limited. The raw key is shown once in the response that made it — it used to travel in a redirect's query string, and so in browser history and the proxy's log.

129. A release written by hand keeps its notes. Publishing a release in GitHub's UI is how releases are usually made here, and it pushes the tag, so the release already exists when release.yml runs. The step that writes notes (softprops/action-gh-release) keeps an existing body only when it is given none of its own, and it always is. v0.67.0 lost its hand-written Security section that way, advisory links and all, ten minutes after it was published. So the workflow looks for a release first, drafts included, and writes one only when there is none: a tag pushed from the command line still gets the generated notes and the docker pull line. A hand-written release carries whatever its author put in it, so include the docker pull line there. The lookup failing stops the job, because reading a failure as "no release" is the overwrite again.

130. A currency is a three-letter code, and the pages treat data as text. GHSA-g75f-hwjq-whr6. An instrument's currency was free text in three places (adding a holding, the trade form's new instrument, the broker designer) and only upper-cased, and SQLite does not enforce String(3). The chart builder's filters and the chart tables built HTML from strings, and the CSP allows inline handlers, so markup typed as a currency ran for whoever opened a chart next. Instruments are shared by every portfolio on an instance, which made that any writer's script in anybody's session.

Two fixes, because either alone leaves a gap. The way in: currency_problem at each route, a BrokerFormat validator for formats from config, templates and the designer, Yahoo's suggestion held to the same rule, and a model validator under all of them that refuses rather than normalises. The way out: the builder, the chart tables and the rotation editor build elements and set text, and a table cell is markup only when charts.js made it so. The second half is what protects an install that already stored a bad value. A bad broker format already installed is skipped with a warning rather than taking the imports page down.

131. Typed text is refused, never cut; values the app picks are cut to fit. Issue #60. The inputs carry maxlength, which a direct POST ignores. SQLite stores any length in a String(n) column and Postgres refuses with "value too long", so the same request was a 500. textfield.fit checks the length against the column, which it reads from the column so it cannot drift from the schema, and refuses, saying how long the text was and how long it may be and none of the text: a form carries the message in a query string, and a pasted page would make that URL too long for the proxy. A note somebody typed is theirs, and a cut one is a sentence they did not write.

The exceptions are values the app chooses itself, where there is nobody to ask: "Alex's portfolio", an API key's fallback name, the User-Agent, and a blank name that falls back to the email. Those are cut with [:n], because refusing would block what the person actually did. Empty text keeps each site's own rule: a note becomes None, a user's name falls back to their email, a plan's to "My plan".

Two details that were found by running it. An email is measured after .lower(), because that is what is stored and some characters grow ("İ".lower() is two characters). And the sign-in and recovery forms refuse an over-long email before it reaches login_attempt, with the answer they give for any wrong email, because anyone can post there and a 500 would be a way to cause errors without an account. Adding a text field means textfield.fit at the route and a test that asserts nothing was saved: SQLite cannot show the failure, and CI's Postgres run is where it would.

132. Every route is walked with values a form cannot send. Issues #53 and #60 were the same bug in different fields: a value the page's own form could not produce got past the server. Fixing fields one at a time leaves the next one open, so tests/test_hostile_input.py takes its routes from the app rather than from a list, and checks the database rather than the status code, because SQLite stores what Postgres refuses. A careful control request goes first and last on every route, because a walker whose requests are all refused at the door passes without having tested anything.

Its first run, on top of #61's fixes, found 101 more: NUL characters (which Postgres refuses even in a lookup), ids past what a column holds, values from Yahoo or an identity provider past their column, and assorted crashes. They are listed in KNOWN and fixed in follow-ups. The list fails when an entry stops happening, so it can only shrink, and a new finding never goes on it.

133. What any request can carry is refused once, at the edge. Three of the walker's findings (#132) were not about any one field, so they are handled in one place each rather than per field, which is how #60's gaps happened:

  • A NUL character. Postgres refuses text containing one even to look something up, so it was a 500 wherever one reached a query. refuse_nul answers 400 for one in the path, the query or a form or JSON body. An upload is let through, because a file is bytes and a PDF is full of NULs.
  • An id no row can have. From 2**63 the lookup itself failed, on both backends. Every id a route takes is a models.RowId, bounded by what an INTEGER column holds, so FastAPI answers 422 first. In a form it has to be Annotated[RowId, Form()]: RowId = Form(...) replaces the bounds and drops them without a word.
  • A bare NaN in a JSON body. Python's parser accepts one, and FastAPI's 422 echoed it back and then could not encode it, so a request rightly refused became a 500. The validation handler gives a non-finite number back as text.

134. What a provider says is held to its columns too, and cut only where cutting is harmless. The walker (#132) sent Yahoo's lookup and an identity provider's claims the same values as a form, and found each of them reaching a column it did not fit. #131 says values the app picks are cut to fit, but that only works for a value that is still itself when cut:

  • A name is cut. Yahoo's instrument name (in pricefeed.lookup, once, for every caller) and a provider's display name, with any NUL removed.
  • An email is never cut, because a shorter address is somebody else's. A new account whose email cannot be stored is refused with a message. A returning identity keeps the email already on file, because an email only describes them and is no reason to lock them out.
  • A subject is never cut, because the subject is the identity. One that cannot be stored refuses the sign-in, checked before anything is looked up (Postgres refuses a NUL even there) or made.
  • A provider's session id is dropped when it cannot be stored. It exists for back-channel logout, and without it that one session ends only here.

And the callback rolls back whatever a refused sign-in started, so an account made for an identity that then fails to link is not left behind with no way in.

135. A plan starts within the next ten years. A plan saved with a start in 9999 was accepted, and then /schedule failed on every load, overflowing the date arithmetic that draws the rotation. That is the page where the plan would be edited, so the portfolio was stuck until somebody changed the database. The start date anchors a rotation that may have begun years ago, so the past is left open. The future is bounded at PLAN_START_YEARS_AHEAD, ten years, far past any plan anybody makes and well short of the overflow.

136. A migration on SQLite runs in one transaction, so a failure leaves nothing behind. pysqlite opens a transaction only before INSERT, UPDATE and DELETE, so Alembic ran SQLite's DDL outside one ("Will assume non-transactional DDL"). On 2026-10-03, 0010 met a database whose index had an older name: it created api_key_portfolio, failed to drop the index, and kept the table, so every restart failed "already exists" until the database was repaired by hand. SQLite can roll DDL back; it was the driver that stopped it. env.py now turns the driver's transaction handling off, emits BEGIN itself, and tells Alembic the DDL is transactional, so a failed upgrade leaves the database at the revision it started from and fixing the cause is enough. The cost is that no migration may need to run outside a transaction: PRAGMA foreign_keys is ignored inside one and VACUUM refuses to run, and none of the current ones do either.

137. The release's tag is the only place a version is written. The Helm chart picks the image it runs from its appVersion, which was kept by hand in Chart.yaml. It said 0.64.0 for every release from 0.64.0 to 0.67.1, so every Helm install ran 0.64.0 however often it was upgraded, without the fixes four advisories told people to upgrade for. pyproject.toml said 0.64.0 beside it, and the test comparing the two passed because neither had moved. Now the release packages the chart with the tag as its version and appVersion and pushes it to ghcr.io, and the project reads its version from the tag through hatch-vcs. The chart in the repository names no release and refuses to render without image.tag, rather than run an image it was not given. A release needs no commit beforehand; the cost is that the chart is installed from the registry, not from a clone.

138. A provider sign-in does not ask for Stocktake's own code. An account with 2FA on gets the code step after a password, but not after signing in through its identity provider: login_oidc_callback creates a full session. The provider authenticated the person, and its own multi-factor policy is where an organisation running one enforces a second factor; asking again would give the people who chose a provider two second factors for one sign-in. It is the reasoning a passkey already follows (#113). The cost is that turning on OIDC trusts the provider as far as the app's own 2FA, so use one that enforces MFA. test_a_provider_sign_in_does_not_ask_for_the_code pins it.

139. A portfolio is shown its own instruments, and removes only its own. The catalogue stays shared (#125): one row per exchange and ticker, with one price history. What changed is what a portfolio sees and can do with it. Manage holdings and the trade and plan dropdowns list the instruments this portfolio has — a holding setting, a trade, a dividend or a place in its plan — where they listed the whole catalogue, which showed every portfolio the tickers the others had added. Remove drops this portfolio's setting; the feed stops fetching an instrument only once no portfolio has it, which is asked after the removal commits, because the question goes through a session of its own. Only this portfolio's trades and dividends block it, and another portfolio's are not counted on the page. One in the plan comes out of the plan too, with a warning rather than a refusal; the rotation closes up and carries on from the last buy still in it, because the slots left keep their rows. A ticker another portfolio added is reused through "Something not listed", and the add form says "added" either way. "Delete all trades" clears this portfolio's trades and distributions for one instrument and leaves it listed, so removing it stays a step of its own. The API adds an instrument as the add form does, given its exchange and currency, and answers alike whether or not the catalogue had it: refusing one another portfolio added would tell this portfolio it was there. A currency that disagrees with the catalogue's is not refused for the same reason; the catalogue's stands.

140. A password is 10 to 64 characters; how strong it is gets shown, never enforced. The length is the only rule, at every form where a password is chosen and never at sign-in, so an account with a longer one from before still gets in. No mix of character types: NIST 800-63B-4 and OWASP ASVS 5.0 both forbid it, and on a self-hosted app a gate on strength mostly produces Password1!. No banned list either, though both ask for one: the meter shows a common password as Very weak, and the person decides. The meter under each chosen password is zxcvbn (Dropbox, USENIX Security 2016), which estimates guesses from what people actually choose, the app's name and the person's own email included; it is a word and a bar, and a weak password still saves. zxcvbn is 800 KB, so it loads on the first keystroke rather than with the page. Admins' temporary passwords get the cap but no meter: the person replaces them at first sign-in.

141. A sign-in opens the default portfolio, else the last one opened. Every way of signing in goes through _landing_portfolio: the portfolio chosen in the profile, else the one in a cookie set whenever a portfolio is switched to, created or joined and whenever a sign-in lands, else the first one owned. A password still waiting on its code sets nothing, so it learns no id. The cookie holds the portfolio's id and nothing else, lives a year from the last time it was set and survives sign-out, which is the point. It is a hint, not a credential: both it and the default count only while the person is a member of that portfolio, so a cookie naming someone else's opens nothing of theirs, and a value that is not an id is ignored. Its name follows the session cookie's, so two apps on one host keep theirs apart. The profile offers the choice only to someone with more than one portfolio. An invite still opens the portfolio it invited to.

142. An export's text is shown by a spreadsheet, never run. Notes, names and Yahoo symbols are typed by people, and the catalogue is shared, so a writer in one portfolio reaches another's export, where a cell starting with = is a formula: a HYPERLINK that sends the sheet's numbers elsewhere (GHSA-w8x6-x99c-773x, from freeb5d). Both renderers neutralise it, not each report, so a report added later is covered too. CSV has no cell types, so text starting with =, +, -, @, a tab or a carriage return gets a leading quote, OWASP's rule, and the quote shows. XLSX has them: every text cell is written as text, which is shown and never evaluated, with nothing added. Numbers are never touched, so a negative figure keeps its sign.

143. A holding's shared details can be corrected where that reaches nobody else. Name, class, currency and price symbol are the shared catalogue's, so changing one changes every portfolio that holds the instrument. They are fields on Manage holdings only for an instrument this portfolio alone has, or for an instance admin; elsewhere they are plain text. "Has" counts a holding setting, a trade, a distribution or a place in a plan, asked across all portfolios and answered as ids alone, as _anyone_has is. A new price symbol keeps the prices already stored and fetches its own from the next run, and a blank one is the guess, as when adding. This replaces the "no way to edit a class" of 2026-10-09: test users needed to fix a NASDAQ stock saved as MSFT.AX. A row only saves for an instrument this portfolio has. A new currency forgets the rates recorded under the old one, in every portfolio: an AUD row holds 1, and kept under USD that 1 books US dollars as Australian (#5). They become 1 again for AUD, and otherwise unknown until the feed fills them from the stored series. Where another portfolio has the holding too, the admin is asked first, and every reset is logged with whose rows it changed: it is the one bulk update on a request path that crosses portfolios.

144. A trust's distribution belongs to the year its period ends. A unit trust's income is assessed for the trust's year to 30 June (ITAA36 s97), so a June-quarter distribution paid in July is income of the year just ended and is on that year's AMMA statement. Filed by payment date, it landed a year late. Each distribution can say when its period ended (dividend.period_end), and the FY report and the dividends export file it in the year holding that date (fyreport.income_date); left empty, the payment date decides, as it does for a company's dividend, which is assessable when paid. The ex-date would not do: a June-quarter fund goes ex on the first business day of July, which is the wrong year too. The cash is still converted at its payment date's rate, and the charts still place it on that date, because they show money moving. A period end comes on or before the payment, and within a year of it.

145. Foreign tax withheld is income, and recorded beside the cash. A US dividend arrives net of the tax the US kept. The assessable income is the whole of it, and the tax kept is a foreign income tax offset (ITAA97 Div 770), so recording only the cash understated the income and lost the offset. dividend.withholding_tax holds what was kept, in the distribution's own currency: cash_amount stays the cash received, because that is what the portfolio's total return and every chart count. The FY report converts the two at the same rate and shows the tax in its own column where any was withheld; the grossed-up figure in the export and the API is cash plus franking plus the tax withheld.

146. A split or a capital change is an event on the holding, not a trade. None could be recorded. A split typed as a free purchase restarts the twelve-month discount clock on every new unit, and a return of capital or an AMIT adjustment had no way to reach the cost base. Each is a corporate_action row per portfolio, like the trades it changes, and every walk over a holding reads one timeline of both (queries.timeline), where an action takes effect from the start of its day. A split scales the units and keeps each parcel's cost and date; the holding's new total is rounded once to the quantity column's last place and shared between the parcels by running total, so the balance check and the FIFO walk agree to the unit (a parcel left with none gives its cost to the next). A return of capital lowers each parcel's cost by its share of the units, an AMIT adjustment raises or lowers it, and whatever takes a cost below nil is a capital gain on the day (CGT events G1 and E10), listed with the year's sales as what it was. One with no AUD figure, for the change or for the parcel, is listed unpriced, and the year names the holding as it names a sale it cannot price (#5). A return of capital is money back in total return; an adjustment moves no money. The price history is stored as fetched: a history fetched after a split holds Yahoo's split-adjusted closes for the days before it, so a holding added after its split charts low before that day. Actions stay in the portfolio they were recorded in when trades move, each side walked with its own. A CSV import walks a holding with a split row by row, since its running totals cannot take a step that rounds.

147. The tax rates are the portfolio's, and the API may set them. The sale simulator taxes the gain a sale adds at two rates kept apart: the taxpayer's top income tax rate, and the Medicare levy. Apart because from 1 July 2027 the minimum tax on capital gains compares 30% with income tax alone (#26); 0019 split the one rate 0018 stored, which had the 2% levy in it. One rate, not brackets, on purpose: the simulator is an approximation and says so. They are stored on the portfolio, beside its brokerage, because the FY report already treats a portfolio as one taxpayer with its own carried losses, and a person with their own shares and an SMSF has two sets. The owner sets them (#34), and anyone simulating a sale can try others without saving them. They are the one thing the API writes in place (PUT /api/v1/portfolio/tax-rates): #88 keeps a client with a bug from rewriting the ledger, which a setting cannot do. Whether the taxpayer gets income support is beside them, for the same reasons (#151).

148. A sale is simulated with the year's own figures, and taxed on what it adds to them. Simulate sale (simulate.py) works out what selling some of each holding today would bring home after tax, and records nothing. Each sell is a plain object on a stand-in for its instrument, dated today at the latest price and today's rate, after anything recorded for today, and goes through the same fyreport.walk and fy_cgt as a recorded one. The ATO taxes the year's net capital gain at the marginal rate, after the year's losses, the losses carried in and the discount. So the tax is the difference the sale makes to that figure, not its own gain halved: a carried loss covers the first of the gain, and a loss that lowers the year's gain lowers its tax, which counts in what the sale brings home. One rate covers the whole gain (#147), and the guide says that a gain crossing a bracket is taxed partly higher. Brokerage is the trade form's suggestion (brokerage.for_trade) unless one is typed. A percentage of the portfolio is that share of every holding, rounded down, so the sale is never more than was asked. A holding with no price, no rate or no FIFO cost is not offered. The form is a GET, because nothing changes, so anyone who can see the portfolio can use it.

149. The CPI comes from the ABS, as first published, from the September 2027 quarter. The 2027 rules index a cost base by the All Groups CPI for the eight capitals: the number for the quarter of the sale over the number for the quarter the money was spent, to three places rounding up from 5, and not at all at 1 or less (ITAA 1997 ss 960-275(1B), 960-275(5), 960-270(2)). The price feed asks the ABS's data API for it, as it asks for exchange rates: the request names a public statistic and carries nothing about a portfolio, and with the feed off nothing is asked. The number for a quarter is the one first published (s 960-280(1)), so a stored quarter is never replaced by a revision; a new reference period replaces the series (s 960-280(3)), since numbers on two cannot be divided. An asset held through 30 June 2027 is treated as bought again on 1 July 2027, so no earlier quarter is ever needed, and nothing is asked until the September 2027 quarter has finished, then only while a finished quarter is missing. The app knows only what it fetched: a number revised before it first asked is stored as revised.

150. From 1 July 2027 a parcel held through 30 June 2027 is split at its value then, and indexed after it. The 2027 rules (Treasury Laws Amendment (Tax Reform No. 1) Act 2026) take an asset held through 30 June 2027 to be sold just before 1 July 2027 for its market value and bought again on 1 July 2027 for that (ITAA 1997 s 112-155). Its gain or loss to then waits for the real sale, and is discounted if the asset was held twelve months by it (s 112-160). From 1 July 2027 the cost base is indexed instead, for an asset acquired twelve months before the sale, counted from the real acquisition (s 114-10(9)). fyreport.walk deems every parcel at its first row on or after 1 July 2027, or at the end for a holding with none. A parcel keeps what it cost, for the holding and the dashboard, and gains three running shares, billed as its units are sold the way its cost is: its value then, its cost base then, and its cost base after.

The value is the last close in the week before 1 July 2027, at 30 June 2027's rate: the market value, which is what the Act uses until the Minister makes the apportioning method s 112-185 allows. With no close that week the parcel is not valued, and its sales are named and left out (#26), rather than valued at a close months old. Indexing weighs each amount in the cost base from 1 July 2027 by its own quarter's factor (#149): the value from the September 2027 quarter, a later increase from its own. What comes back, a return of capital or a decrease, comes off unindexed, since a reduced cost base is not indexed, and indexing cannot make or grow a loss: proceeds between the cost base and the indexed one are neither. The year's losses reduce the deferred gains first, those with no discount before the discountable, then the gains from 1 July 2027 (the amended s 102-5(1)); losses carried in follow the same order; the discount comes last. What is left of the gains from 1 July 2027 is the minimum tax capital gain (Div 119), which the FY report and the API carry.

Every page and export shows a sale's cost as the holding counts it, what was paid moved by what came back, beside its indexation: proceeds less both is the gain, whatever happened after 1 July 2027. The cost base from then is a tax figure, not what the holding cost. A quarter's CPI is out about four weeks after it ends; until then an FY report leaves the sale out (#26), because a figure for a tax return never estimates. Simulate sale and the unrealised gains export are about a sale today, so the latest published number stands in, and the simulator names it.

151. The minimum tax is worked out at the one income tax rate, for the year, unless the taxpayer gets income support. From 1 July 2027 an individual pays extra tax of 30% of the year's minimum tax capital gain, less the basic income tax that gain adds, rounded down to a whole dollar, when that is more than nil (ITAA 1997 s 119-10). The gain is what is left of the gains from 1 July 2027 after losses: the deferred gains are not in it, and the losses reduce those first (#150), so fyreport carries it and the API publishes it. The basic income tax the gain adds is taken as the gain at the portfolio's income tax rate, the same approximation the simulator makes for the year (#147); the Medicare levy is no part of it, which is why the two rates are apart. The extra is worked out for the year and rounded down there, so a sale adds the difference between the year with it and without it, not its own share rounded. Nobody who gets an income support payment in the year pays it (s 119-15), and the app cannot know who does, so it is the portfolio's setting, income_support, which the owner ticks and the API sets beside the rates. It records the fact, not the conclusion, so a client that knows the payments sets it without knowing the rule.