How LendScope is verified
This page is built from the code, not written about it. Every item below is read from the test suite, the lender policy overlays and the panel data file when the site is built, so it cannot say something the code does not do. 827 behaviours are pinned by automated tests; 9 of 51 lender policies are transcribed from the lender's own broker workbook with a cell reference per figure; rates are dated 3 Sep 2026; the panel's 82 recorded changes are diffed from history.
Where lender policy comes from
A lender is verified when its assessment floor, buffer, shading, card factor and thresholds were read from the lender's own workbook, each with the sheet and cell it came from, and the fields that could not be found are listed as unverified. Draft means public broker guides, not cell-checked. Generic means the panel-wide defaults. The calculator shows which applies on every lender row.
| LENDER | SOURCE WORKBOOK | CITED FIGURES | LISTED AS UNVERIFIED |
|---|---|---|---|
| National Australia Bank | nab-broker-serviceability-calculator-v63.xlsm | 21 | 2 |
| ANZ Bank | ANZ-Home-Loan-Calculator.xlsm | 18 | 7 |
| Macquarie Bank | macquarie-bank-mortgage-solutions-serviceability-calculator.xlsm | 18 | 5 |
| AMP Bank | amp-bank-serviceability-calculator.xlsm | 24 | 5 |
| Bankwest | serviceability-calculator.xlsm (Bankwest) | 19 | 5 |
| Auswide Bank | awb-loan-serviceability-calculator-111a.xlsx | 17 | 5 |
| MyState Bank | loan_service_calculator.xlsx | 17 | 5 |
| ME Bank | MEB CAT v1.15 - Broker (Locked).xlsm | 16 | 6 |
| La Trobe Financial | latrobe-serviceability-calculator.xlsm | 18 | 5 |
Draft (public guides, not cell-checked): Commonwealth Bank, Westpac, Bluestone, Bendigo Bank, Bank of Queensland, Heritage Bank, Bank Australia, Qudos Bank, Community First Bank, Teachers Mutual Bank, Beyond Bank, BankVic, Gateway Bank, Firstmac. Generic defaults: 28 lenders. Send us a lender's broker workbook and it moves to verified.
What the tests pin
Each line is the title of one automated test in the repository. They run on every push, and the result is recorded against the commit on GitHub; the deployment itself is not gated on them.
anon-surface · 12
Cross-cutting sweep of every SQL file and Edge Function that an anonymous
- every security-definer function pins search_path, and the ones that mint tokens can see pgcrypto
- no function keeps the default PUBLIC execute: each is revoked from public and granted to a named role
- only the nine intended RPCs are executable by anon
- the public Edge Function (panel-subscribe) has no cron gate but validates, rate-limits, and never answers by what the row looked like
- every table has RLS on and is revoked from anon at the privilege level too
- the anon-facing RPCs answer the same way for unknown, expired and revoked tokens
- the constant-time secret compare behaves: equal → true, any difference or missing value → false
- CORS: no blind reflection — only the production origin, localhost, or the ALLOWED_ORIGINS env are echoed
- the post-login redirect helper and the public pages render server data as text only
- client-errors.sql: no client privilege on the table, an authenticated-only RPC with caps, stripped keys, rate limits and a service-role prune
- schema.sql hardening (T2): the three original tables are revoked from both client roles and granted back by allow-list
- the two bigserial sequences are revoked from both client roles (default grants reach sequences too)
app-pages-a11y · 16
App-page accessibility: dashboard, lenders, portals, calculators and settings.
- dashboard: the quick-scenario inputs are labelled, every button is named, icons are hidden, the table has scope and a hidden Actions header
- dashboard.css: labels, subs, headers, metas and the $ prefix are --neutral-text; badges use the AA steps; panel titles are h2
- dashboard: every colour a hovered row paints clears 4.5:1 on the --accent-light row tint
- lenders: no row is role="button"; each has a real expand button and an aria-pressed switch, both named; closed and switched-off rows are greyed by colour, not opacity
- lenders: the type pills and the Portals category colours clear 4.5:1 on their tints and on white
- lenders.html and pages.css: the search is named, the filters are toggles, the stat is not green, and the row classes sit on the neutral token
- policy-qa.js: the Ask dialog is labelled by its h2, its textarea has a label, focus is trapped and returned; the AI settings fields are named
- portals: the favicon is decorative, the link says it opens a new tab, hosts and counts are neutral, filters are toggles
- calculators.html: every input and select in a calculator row is labelled by its own row\'s cell; the rail tabs are aria-pressed toggles
- calculators: under 768px the rail is a horizontal strip that scrolls inside itself with 44px tabs, not a sticky column over the pane
- settings: every section renders with each input, select and switch named, no icon-only button, nav icons hidden and the active section pressed
- every app page has exactly one <h1> — the page title — and join / factfind keep theirs
- shared.css: stat labels and subs are --neutral-text and the pills use the AA steps; the neutral pill pair clears 4.5:1 where #6B7280 did not
- dashboard: the recent-scenarios table scrolls inside a labelled, focusable wrapper so the page never scrolls sideways at 375px
- settings: the AI key grid and the Team copy fit the 203px phone column — neither tab widens the mobile layout viewport
- body copy in the states axe never rendered is --neutral-text, not --faint
applicant-import · 6
Applicant import: header guessing, saved-mapping precedence, value parsing,
- headers map by synonym regardless of case, spacing and punctuation
- a field is claimed once; a saved mapping beats the guess
- CRM presets add hints but are flagged unverified
- rows become applicants with parsed money, employment and dependants
- a bare dependant count becomes that many mid-band ages; rental income marks the property as investment
- rows with no income and no name are dropped; at most six applicants
apply-sql-failures · 17
How scripts/apply-sql.py decides a statement failed, and what it does next.
- a temp-role auth failure is a connection failure, and is retried
- the exit-0 auth failure carries none of the words the old check looked for
- other transport failures are connection failures too
- a genuine SQL error is fatal and is NEVER retried
- a connection blip the CLI recovered from does not mask a later SQL error
- a run the CLI retried and completed is a success, not a failure
- output that ends on the retry line is never reported as applied
- the failures the old check caught stay caught
- clean output is clean — a NOTICE is not a failure
- every fixture is exercised by a test in this file
- the verification plan names the object classes that went missing
- the plan is a floor: a conditional dynamic create is left out
- the plan never claims an object the file does not create
- a verification result is read strictly — only kind/ident rows count as missing
- a field the CSV writer chose to quote is still read
- a result the parser did not understand is never read as "nothing missing"
- an "add column" inside a comment does not invent a column to verify
bankwest-carded-rate · 6
Bankwest is assessed at its own carded rate for product × LVR tier, buffer
- the headline Bankwest rate is the default tier\'s owner-occupier P&I carded rate less the buffer (a rate refresh cannot desync them)
- tier boundaries: the engine and the workbook profile pick the same tier for every LVR
- cardedAssessRate: owner-occupier / investor × P&I / IO, and null where the table says n/a
- computeResults assesses Bankwest at the tier rate and says so on the row; every other lender keeps the entered rate
- a broker override still wins, and an n/a tier falls back to the entered rate + buffer and is labelled
- the transcribed tiers are the workbook\'s own cells (Maintenance rows 79-84, columns E/F/K/L)
broker-channel · 8
Three lenders on the panel are not accepting new broker applications:
- exactly three lenders are flagged, each with a date and a source
- the panel still has 51 lenders — they were greyed out, not removed
- the label names the month and year the channel closed
- a closed lender never supplies a lowest-rate claim
- a closed-channel lender never takes a podium place, and the note says why
- a closed-channel lender keeps its row and its figures, labelled and de-emphasised
- the calculator table, levers, PDF and audit pack all carry the flag
- the pass count a saved scenario stores leaves closed channels out, as the calculator does
calc-a11y · 11
Calculator accessibility: every rendered input has a name, the page has one <h1>, muted text never sits on --faint, and the token pairs clear 4.5:1.
- every input and select the calculator form renders has a label or an aria-label — title is never the only name
- every button the form renders has visible text or an aria-label — an icon glyph is not a name
- the collapsible card headers and the expand chevrons are keyboard-operable and announce their state
- the progress strip renders its steps as buttons, marks the current one, and keeps the tick decorative
- calculator.html has exactly one <h1> — the scenario name input, with a real <label for>, no title
- the podium and share views each have one <h1> and their section heading is an <h2>; the results table scrolls inside a labelled, focusable region
- the tokens the fixes moved text onto clear 4.5:1 on the surfaces they sit on, and both CSS layers agree
- calculator.css: the amber "deep" step is the AA amber, and no body-copy class sits on --ls-faint
- podium.css: chip keys, stat labels, headers and lender type are on --neutral-text; the status pills and counts use the AA steps; closed rows are greyed by colour
- the JS renderers colour no text with --ls-faint — only Material Symbol icons keep it
- every lender monogram clears 4.5:1 on its own tint over white, the closed-row surface and the row-hover tint (lenderMonoColors, now in shared.js)
crm-contact-detail · 7
crm-contact-detail.html — one contact, their deal, and the history
- Call, Email and SMS are real links, and say why when they cannot be
- the activity feed is built from signals this product records
- the prototype\'s invented history and its empty tabs are not shipped
- details and the deal come from the scenario, so they cannot disagree with the calculator
- "Lenders passing" is out of the open lenders the broker has switched on, as the calculator counts it
- notes are the contact\'s own field, saved through the allow-listed column
- the page is a signed-in app page, reachable from the list and the board
crm-contacts · 10
crm-contacts.html — the CRM contacts page (2026-09-04).
- the write path sends only the columns the table grants UPDATE on
- the shared confirm dialog stays escape-only — the contact form is its own
- every value that reaches the page is escaped — it is all client PII
- none of the prototype\'s sample people, and no column the schema cannot fill
- a workspace without the table is told, not broken
- the page is a signed-in app page, reachable from the rail
- filtering and search work on the fields a broker would type
- stage, loan and LVR come from the contact\'s furthest-along OPEN deal
- the Source and Stage filters are real selects, and they compose
- Owner resolves through list_org_members, because profiles is own-row-only
crm-css-coverage · 1
Strip comments so a class named only in prose ("we used to use .foo") is
- ${page}: every class it paints has a rule in the CSS it loads
crm-dashboard · 12
crm-dashboard.html — the CRM dashboard ported from the Claude Design
- none of the prototype\'s sample people, figures or fabricated metrics reached the page
- every panel reads the signed-in broker\'s own deals, through dbListDeals
- a panel with no backing table names what is missing rather than showing a figure
- stage colour follows DESIGN.md — three depths plus the two outcomes, never a seven-hue ramp
- no inline style and no inline handler: the tint and the bar width travel as data attributes
- the page is a signed-in app page: auth, the sink, the banner, one h1, a labelled scroll region
- the helpers agree with the deal list about what is overdue, open and recent
- the KPIs are four figures this workspace can actually source
- the funnel counts and values real stages, and drops the lost pile
- the layout cannot stretch the mobile layout viewport: every track shrinks below its content
- both CRM pages are reachable: a rail entry in nav.js AND the calculator\'s hardcoded mirror
- the app-wide shell: one width token, a navy panel, sections, and a collapse the prototype never had
crm-documents · 10
client_documents — the metadata layer, the path rule, and the two orderings
- the bucket is private, and re-running can only re-close it
- DELETE is object first, then row; the policy is path-based so neither order can strand a file
- UPLOAD is object first, then row — and the object is taken back down if the row fails
- the path a row claims and the path storage allows are the same rule
- the write path stays inside the column-level UPDATE grant
- a document dies with its person and detaches from its deal
- anon is revoked and the vocabulary is closed
- size is capped in both places, and reported honestly
- the Add file control is reachable by keyboard
- the page no longer claims documents and tasks have no store
crm-fact-find · 8
crm-fact-find.html — the broker's fact-find review (2026-09-04).
- it reviews and links — it does not become a second editor for the calculator\'s columns
- completion is measured from the scenario, never a fixed percentage
- the section the model cannot hold is excluded from the score, not counted as a failure
- assets and compliance are absent for want of data, and say so
- what is missing is derived from the scenario, not a fixed checklist
- the position summary computes, and omits what it cannot source
- the page is a signed-in app page with one h1 and no inline style
- none of the prototype\'s sample person
crm-new-contact · 9
crm-new-contact.html — a contact with no deal attached (2026-09-04).
- an email or a mobile is a near-certain match; a name alone is only a prompt
- the strongest signal is listed first — a near-certain match must not sit under a coincidence
- a half-typed form does not flood the panel with matches
- only filled fields are sent, so an untouched box does not blank a column
- every field maps to a column the table actually has
- the prototype\'s three unkeepable promises are not on the page
- what has no column is not asked for
- contact type is one value, because the column is
- it is a signed-in app page, labelled, with one h1 and no inline style or handler
crm-new-lead · 9
crm-new-lead.html — the intake form (2026-09-04).
- the page promises only what this workspace actually does
- the invented first-touch SLA card is not shipped
- money parses forgivingly but never silently becomes zero
- the enquiry figures seed a real scenario, and absent ones stay absent
- the five purposes map onto the taxonomy the table already checks
- a half-failure says so instead of implying nothing was saved
- the purpose control is operable from a keyboard, unlike the prototype\'s
- every field is labelled, and the page is a signed-in app page
- none of the prototype\'s sample people or hardcoded assignees
crm-pages · 19
The four remaining CRM pages — activity, calendar, deal book, reports
- every rail item has an icon, and the calculator mirrors the rail
- each page is a signed-in app page, reachable, with no inline style or handler
- activity: no invented email metrics, and the feed dates itself
- activity: the weekly counts come from the log and from completed tasks, and say which
- activity: an event with no recorded "from" does not read "from undefined"
- the log is fire-and-forget in, strict out
- calendar: the grid is computed, Monday-first, and pads whole weeks
- calendar: entries are tasks and next actions, never a second table
- deal book: no invented probability and no invented settlement date
- deal book: purpose and LVR are derived from the scenario, not guessed
- a monogram is the same badge for the same person on every CRM page
- calendar: an entry that is past and still open reads as overdue
- deal book: the CSV is what the table shows, and quotes what it must
- reports: the prototype\'s figures are gone and no year-on-year delta is claimed
- reports: a settled deal is dated by the log, never by updated_at
- reports: months with nothing are still months, and the funnel counts what deals reached
- the CRM settings are a section of settings.html, not a second settings page
- the CRM vocabularies are shown, not offered as editors that cannot work
- reports: a contact with no source is its own line, not spread across the others
crm-pipeline · 7
crm-pipeline.html — the kanban from the Claude Design project,
- a deal can be moved without dragging: every card carries a labelled select
- a move actually writes, and closing a deal is confirmed first
- the board is built from the real stages and the broker\'s own deals
- no inline style, no inline handler, and the page is a signed-in app page
- the card figures are computed, and drop out when the inputs are missing
- the board refetches when another tab moves a deal
- nothing escapes to stretch the page: the card is positioned and the board owns its own scroll
crm-tasks · 7
crm-tasks.html — the broker's follow-up list (2026-09-04).
- overdue and due-today are computed from due_at, never stored
- overdue sorts first, then by date, and an undated task never jumps the queue
- the filters mean what they say
- the write path stays inside the column-level UPDATE grant
- an unknown kind or priority is dropped rather than sent to be rejected
- the prototype\'s sample tasks are not shipped, and a missing table says so
- it is a signed-in app page, in the rail, with the calculator sidebar mirrored
csp-inline-styles · 5
The CSP's style-src carries no 'unsafe-inline' (2026-09-03). Under that
- no served .html file carries a style attribute or a <style> block (the CSP blocks both silently)
- no js/*.js file or HTML generator emits a style attribute, a <style> element or setAttribute("style")
- no stylesheet keys a rule on an inline style attribute ([style*=…]) — there are none left to match
- vercel.json: style-src allows no inline styles on the app, the auth pages or the embeds; the ad-bearing public pages allow it for Google's injected container only
- applyDataStyles (js/event-delegation.js) is the one route from a data attribute to a style, and it is an allow-list
dashboard-samples · 6
First-run samples on the dashboard: the three demo files the empty state
- the dashboard offers three demo files, each present, parseable and listed in the README
- each sample survives the sanitiser with every input intact
- every demo property carries the state and postcode its address names, so Funds to complete is populated on load
- importScenarioFromJson saves with the " (sample)" suffix, through dbSaveScenario when present, and the result is never blank
- the file importer and the sample loader share importScenarioFromJson; samples are only offered to an empty library
- nothing in the deploy config stops demo-scenarios/*.json being served
deal-files · 20
Deal files (Phase 1a-i): six deal columns on scenarios, the owner-executed
- deal-files.sql: the six deal columns, their checks, inputs_updated_at, and no foreign key to lenders
- deal-files.sql: header states the apply order and the hard prerequisites
- deal-files.sql: both older create_factfind_link signatures are dropped before the shell RPC is created, and the grants move to the newest
- deal_files view: owner-executed with the visibility predicate in its WHERE, exactly the documented columns, nothing sensitive
- deal_files view: the stage_rank case is DEAL_STAGES from shared.js, same ids, same order, same ranks
- deal-files.sql: indexes — the partial one is null-safe on manual_stage; the cross-table ones are guarded
- scenario_inputs_changed is defined identically in deal-files.sql and scenario-versions.sql, and the versions trigger calls it
- _rowToScenario / _scenarioToRow: absent deal columns stay undefined and are never sent; present ones round-trip, null included
- dbPatchScenario: only the six deal keys plus passCount, snake-cased; unknown keys ignored; storage signal on success
- dbPatchScenario in demo mode patches the localStorage row in place and signals
- dbListDeals reads the view with count, the nulls-last order and a 500 range; a missing view falls back to the table with source "table"
- dbListDeals in demo mode returns the localStorage rows adapted, source "table"; adaptPlainRowToDeal is pure and idempotent
- dbDuplicateScenario keeps the client and clears stage, lender, next action and pack — explicitly null, so the insert cannot inherit them
- every write path signals ls_deal_changed so an open deal list refetches: save, delete, patch
- import whitelist: the six deal fields round-trip through the sanitiser, importScenarioFromJson and the export
- import whitelist: a hostile file — injected stage, injected lender id, a 2 MB next action, a non-date — drops every deal field and still imports the inputs
- a fact-find shell is not blank: the name alone keeps it out of the sweep
- the fact-find import fills the shell: reads scenario_id, updates the same row, confirms past factfind, falls back to insert
- factfind.sql re-apply hazard is closed: it drops its own two-argument RPC again when the shell RPC exists
- deal_files view: the marker columns come from scenario_recheck, moved_count is zero outside 48 hours, and edited_since_check compares inputs to the last check
deal-list · 31
The Deals page (scenarios.html, js/scenarios.js) — Phase 1a-i of the
- rows from both `source` values render through the one renderDealsList, and the plain source shows the setup notice
- the local adapter turns a plain scenario into the deal shape (stage from pass_count) and the setup notice stays off in demo mode
- the setup notice is dismissable per session
- no <tr> carries a click handler; the deal name is a real anchor to the calculator; the pill and kebab are named buttons
- dealAnnotation: moved sits between overdue and waiting; edited between waiting and set-by-you; a closed deal shows neither
- dealAnnotation: exactly one annotation, overdue > waiting on client > set by you
- stage pill classes: early / mid / late / settled / lost for all eight stages
- overdue rows are pinned first under every sort; the remainder follows the chosen key
- the stage filter defaults to Open and hides settled and lost; Settled / Lost / All behave; prefs are remembered
- a six-row skeleton paints before data, never the localStorage cache
- empty state offers the three dashboard samples as buttons and "New scenario"; no-results is a different state
- a fetch error renders inline with Retry; "Show N more" appears only while the server holds more
- the count line is the anchor: total · overdue · without a next step, with the two counts as filter toggles
- the first-visit strip shows once per browser while deals lack a next step, and is dismissed with localStorage
- "/" focuses search except when focus is in an input, textarea, select or contenteditable, or a modifier is held
- a silent refetch never detaches the <tr> whose next-action editor is open: siblings are replaced around it
- focus returns to the control a re-render replaced: the pill after a stage change, the next-action text after save or Esc, the pressed filter when the row is gone
- the copy-summary modal labels its textarea and both shared modals title with an h2 (axe label: critical, heading-order: moderate)
- inside a layer Tab wraps, Arrow Up/Down and Home/End move between controls, arrows are left to text and date fields, and Esc closes
- the next-action cell stacks text and date chip, uses the placeholder when empty, and closed deals show a dash
- the rail says Deals in both sidebars, the page is titled Deals, and the dashboard link agrees
- privacy notice tells the truth about fact-find links
- the row menu and the sheet carry Pack first (folder_zip) with the chosen lender as its note, and "Change lender…" only once a lender is chosen
- the lender picker sections Passing / Refer / Fail from engine rows, sorts by surplus, disables closed channels with CLOSED, and labels workbook vs PDF only
- last activity reads "Pack · NAB · just now" when the pack is the newest timestamp, and the older cause otherwise
- the pack chain is preloaded on hover/focus only, as <link rel="preload" as="script"> with the CDN entries crossorigin + integrity
- deal-list text colours clear 4.5:1: the stage pairs, the lost pill, the next-action placeholder, the unknown-lender badge, table headers and the rail labels
- the stage and row menus are role=menu; the movements popover and the lender picker are dialogs; the sheet groups its menuitems under role=menu
- lender monogram text is darkened along its own hue until it clears 4.5:1 on its tint; a compliant colour is returned unchanged
- the row-actions kebab opens the bottom sheet at ≤ 1024px (_isCompact) and the popover menu above it; the card layout stays a phone query
- the 768–1024px layout is CSS only: the row DOM is identical at every width, the th carry the c-* hooks, and the tablet block re-flows stage and lender under the deal name
deal-summary · 7
Copy-to-CRM summary (deal files 1a-ii, C4) and the DOM-free hydrateState.
- the list page loads exactly the eight engine files the nightly re-check pins, in order
- hydrateState refuses to run without the engine and lives outside the hash-pinned files
- the summary carries first names and engine figures only — no surname, DOB, address, employer, email or notes
- a closed broker channel that passes is left out of the pass count, and the count says how many were left out
- the summary's pass count agrees with the calculator's hero on a scenario a closed lender passes
- without a chosen lender the summary leads with the top passing lenders; an unknown chosen id is named, not invented
- copyTextWithFallback reports what worked: clipboard, then execCommand, then the modal
design-md · 5
DESIGN.md is written from the CSS, so it must never drift from it.
- every token DESIGN.md names exists in one of the two CSS layers
- every hex DESIGN.md pairs with a token is the value the CSS holds
- the deal-stage and overdue tints are identical in shared.css and tokens.css
- the brand values match across the two layers under their different names
- DESIGN.md carries the memorable thing, the no-gradient and green-is-pass rules, and CLAUDE.md points at it
drawer-dialog · 6
The calculator's Details drawer as a dialog (a11y follow-up 2026-09-03).
- calculator.html: the drawer box is a labelled modal dialog with a named, focusable close button
- trapFocusWithin: Tab and Shift+Tab wrap inside the container, Escape runs onEscape and stops there, and the release removes the listener
- trapFocusWithin: with arrows on, Arrow Up/Down and Home/End walk the controls (the menu pattern) but leave text fields alone; no onEscape means Escape is not consumed
- the deal list uses the shared trap — one implementation, not a copy
- opening the drawer moves focus to its close button and traps focus in the box; closing releases the trap and returns focus to the row\'s Details button
- Escape inside the drawer closes it through the trap; the opener is found by lender id when the click did not focus the button (Safari)
duty-core · 8
duty-core.js was cut out of calculators.js verbatim so the Funds to
- the core exports what the app pages and the engine consume
- every state computes duty and has a fee row and an FHOG entry (ACT deliberately null)
- duty is non-negative and monotonic in price for every state
- first-home relief never increases duty, and exempts a NSW buyer under $800k
- relief is unavailable to investors and foreign buyers
- foreign purchaser surcharge is additive and positive where a state levies one
- LMI is zero at or under 80% and grows with LVR
- FHOG applies only to new builds for first-home buyers under the cap
edge-shared · 17
The three modules every Edge Function shares: CORS, the rate limiter and
- the production site and local development are allowed; nothing else is
- AN UNKNOWN ORIGIN IS NEVER REFLECTED — it gets the production origin back
- an allowed origin is echoed exactly, and Vary: Origin always rides along
- a caller with no Origin gets * — curl and pg_net are not subject to CORS
- ALLOWED_ORIGINS REPLACES the built-in list rather than extending it
- the env list is trimmed and empties are dropped
- the method and header allowlists are the ones the client actually sends
- a key is allowed up to the limit and refused after it
- A REFUSED CALL IS NOT COUNTED — hammering cannot extend the lockout
- the window slides rather than resetting on a fixed boundary
- keys are independent — one caller cannot lock out another
- idle keys are swept, so the map cannot grow without bound
- clientIp takes the first hop, then falls back — never skips the limit
- secretMatches folds length and content into one accumulator
- an unset secret never matches, whatever is offered
- the comparison has no early exit — the property assertions cannot see
- multi-byte secrets compare by bytes, not by characters
embed-parity · 22
The embeddable calculators at /embed/*.html, which run on OTHER PEOPLE'S
- the generator, this test and the shipped pages name the same eighteen calculators
- every embed loads duty-core BEFORE calculators.js, and loads nothing else
- an embed page keeps the pane\'s inputs and their shipped defaults
- every id a calculator reads belongs to its own pane
- every embed computes — no calculator leaves its result as a dash
- an embed and calculators.html print the same answer for the same inputs
- the stamp duty embed prints _stampDutyByState, for every state and band
- the LMI embed prints _lmiPremium, and says so rather than guessing at the edges
- property buying cost is duty-core\'s duty plus duty-core\'s LMI plus the stated fixed fees
- loan repayment: P&I, interest-only and total interest
- interest only: the IO payment, then P&I over what is left of the term
- comparison rate: the rate that amortises loan + fees to the same repayment
- extra repayment, lump sum, fortnightly and offset all shorten the same loan
- loan comparison, switching, selling cost, compound interest and card payoff
- borrowing power: the P&I inversion, and nil when nothing is left to service with
- the income tax embed is the 2026-27 resident scale with LITO and the Medicare shade-in
- the tax embed and the serviceability engine differ on purpose, and both stay pinned
- vercel.json carves out /embed/ for framing and nothing wider
- an embed touches no storage and identifies no host site
- every embed is one document with one h1 and a label on every control
- an in-pane link works from a third-party domain
- every embed carries the indicative / not-advice line, from the generator, and the hub says it outside the FAQ
engine-methods · 5
Engine behaviours added for parity: self-employed method per lender,
- self-employed income follows the lender method: Macquarie caps growth at 20%, the default averages
- investment income is taken at the lender\'s stated factor: 80% on the workbooks that state it, 60% at ME Bank, 100% where none is stated
- fixed structure uses the fixed rate and buffer where on file, and says so where not
- every fixed rate on file is a plausible rate for a real lender and term; a lender without one still falls back to variable and says so
- ANZ LVR rule lowers the cap for an interest-only deal and the row fails LVR at 85%
engine-vs-workbook · 15
Engine versus workbook: the same simple scenario — one PAYG applicant, one
- NAB v63: rate, repayment, net income, HEM and surplus agree with the Calculator sheet
- Macquarie: repayment, tax and surplus agree; HEM differs only by table vintage
- La Trobe: 2% buffer, $15 fee, HEM and result agree; max loan within the $100 floor
- AMP v26.02: repayment, tax, HEM-or-declared and NMS agree
- Bankwest: carded benchmark rate, repayment, tax and HEM agree; the surplus gap is the HEM gap
- ME Bank CAT v1.15: 5.75% effective floor, repayment, tax and HEM agree; the surplus the macro would write matches
- Auswide 11.1a: 7% floor, repayment, tax, HEM and LendFast surplus agree
- MyState: 6% floor, repayment, tax, HEM and LendFast surplus agree
- ${id}: net monthly income at $45k and $60k agrees within $1 — LITO ${id === 'lat' ? 'applied' : 'not applied'}, as the workbook does
- AMP: a HELP balance is repaid on the 2025-26 marginal thresholds, capped at the balance
- ANZ: repayment, net income and UMI agree with SoP 1
- La Trobe: $40k rent is shaded 80% into the taxable income (F32) and the geared interest (C43) comes off the tax base only — net income within $1, both ways
- Bankwest: 90% of the rent less the higher of declared costs and 10% (CD232) is taxed at CA96; the investor loan\'s interest at the carded rate (CA133, AT26 = 100%) comes off the tax base — net income within $1, both ways
- NAB: rent is taxed inside P41 at Shade_NewRent 90% on the average rate of the 100%-rent gross, and Prod_Inv1 puts the loan\'s interest in the tax base — the workbook algebra reproduces Monthly_Net to the cent and the engine sits inside its cited residual
- the output sheets of the eight workbooks stay divergence-free once driven
error-sink · 11
js/error-sink.js — the client error sink in the browser: what it sends,
- reportClientError sends one RPC row: kind, message, allow-listed detail, page = location.pathname
- the kind is normalised to ^[a-z0-9_.-]{1,40}$ and the message is the error text, capped at 500
- identical kind + message within 60 s is sent once; a different message, or the same one after the window, is sent again
- at most 20 reports per page load; the rest are dropped
- detail is reduced to the allow-list before it leaves the page: applicants, email and any other key are gone; values are primitive and short
- signed out (no session) or demo mode (no supabase): the report is dropped silently and nothing throws
- <html data-error-sink="off"> installs no listeners and reportClientError is an inert function
- window 'error' and 'unhandledrejection' are wired: an uncaught Error becomes kind 'uncaught' with its script src (query stripped); a rejection reason is read for message/code only
- every signed-in page loads js/error-sink.js right after js/auth.js; no anon, auth or marketing page loads it
- every reportClientError call site outside the sink is typeof-guarded and names a kind the SQL accepts
- the sink itself never forwards a scenario: no JSON.stringify of a caller value, and the allow-list is the seven documented keys
export-expenses · 7
Living expenses → lender workbooks. `LIVING_EXPENSE_CELLS` in js/xlsx-export.js
- every staged lender maps all 14 living-expense keys — a workbook cell with a unit, or a documented skip
- HEM mode writes nothing, so the workbook applies its own benchmark to the blank rows
- manual mode converts the entry frequency to each cell\'s unit (monthly, weekly, annual) and pins NAB\'s frequency dropdown
- keys folded onto one workbook line are summed, and pre-summed totals cover exactly their keys
- applyLivingExpenses writes into the named sheets, extends !ref, counts cells, and skips a sheet the workbook lacks
- the mapped cells still sit beside the labels they were read from, and are inputs, not formulas (workbook drift check)
- driven in HyperFormula, each workbook\'s own total cells add up the categories written
export-parity · 8
The downloaded workbook IS the verified workbook.
- inputWrites() returns the scenario inputs runScenario pushes — and nothing else
- the export writes the profile inputs, swapping its declared line for the itemised categories
- an exported workbook computes what runScenario computes — assessment rate, repayment, HEM, net income, surplus
- what is written is scenario input only — no HyperFormula patch, no cover sheet, formulas untouched
- an empty value never replaces a template formula, and a real value does
- ANZ keeps the populator fallback: a template with no computing profile is passed through
- every staged template is registered; nine compute and eight export through the profile
- the workbook-mode download button offers the unified export
factfind-intake · 20
Permanent fact-find links, the quick form and client progress — the page
- where the client got to, from an injected clock
- a workspace that does not record progress shows nothing — never "not opened"
- ffAgo buckets
- the list of links still with a client
- the permanent link section: copy, revoke, and a cap of five
- the form picker appears only where the workspace can store the choice
- links still with a client show status and expiry; a minted link names where it came from
- a hostile label, from either kind of link, cannot become markup
- the inbox says which link a submission came through, and leaves room for the check
- OPENING A PERMANENT LINK CREATES NOTHING — minting waits for the Start press
- after Start the page carries on as the client\'s own link, and remembers it on this device only
- the stepper boots after the link resolves, with the link\'s form
- THE QUICK FORM IS LAYOUT ONLY: the same six sections regrouped into three steps
- the broker\'s step counts are the client page\'s
- progress only moves forward, once per step, and never throws into the form
- the summary counts open lenders only, and names the best figure
- the line says what it is, and what it is not
- THE INBOX CHECK RUNS THE CALCULATOR\'S OWN ENGINE on exactly what Import would save
- an engine that will not load leaves the inbox as it was, and one bad submission does not stop the rest
- the Deals page has what the check needs: the engine loader and hydrateState
factfind-payslip · 6
The client's payslip fills their fact-find income — js/factfind-payslip.js
- this pay × pays a year: overtime stays separate, the stated salary wins, allowances fold into base and say so
- a bonus is never annualised, a year-to-date fallback says it includes overtime, and no gross means no figures
- the suggestion is escaped, offers Use and No thanks as plain buttons, and leaves overtime out when there is none
- factfind.html: a picker per applicant, the file inputs carry no id, and the reader loads before the mount
- picking a payslip suggests, "Use these figures" fills base and overtime and fires change (so the draft saves), and nothing is filled before
- a scan is refused with the reader\'s reason; without the reader the picker stays hidden and the form is unchanged
factfind-steps · 9
factfind.html — the stepped client fact find (2026-09-04).
- every field the submit handler reads still exists — the stepping moved nothing
- it is still an anon page: no auth, no error sink
- six steps, and only the first one blocks
- the draft is per-token, and consent is never restored
- nothing the design invented about the broker reached the page
- no promise the app cannot keep: no server-side autosave, no messaging
- the upload promise IS kept — the machinery behind it exists
- the nav row survives the page\'s full-width .btn and the hidden attribute
- an element the page hides stays hidden, even when a class gives it a display
factfind-upload · 19
The client's side of attaching a document to a fact-find.
- a file is accepted only if it is a type the bucket will actually take
- the size and count limits are explained in the client\'s terms, not the server\'s
- sizes read the way a person writes them
- the checklist is rendered from the broker\'s ask, in the client\'s words
- with no checklist it is plainly optional
- attached files are listed, and the control disappears at the cap
- a hostile file name cannot become markup
- THE ORDER: sign, then PUT, then confirm — the row is written last
- every failure is reported and none of them throws
- the page offers uploads only where the whole path exists
- the client half never assumes uploads are set up
- the broker-side import moves the object out of the staging folder
- the client picks what each file is, defaulting to the first ask not yet answered
- THE CLIENT\'S FILE IS CHECKED FOR A TFN BEFORE IT LEAVES THE PHONE
- THE BROKER\'S IMPORT CHECKS AGAIN, AND A FILE CARRYING A TFN IS DELETED, NOT FILED
- a tax return is not scanned on import, by the same rule as every other upload path
- the Deals page can file what a client attached
- a new link carries the broker\'s document checklist
- a workspace still on the two-argument RPC gets a link without a checklist, not an error
factfind · 6
Client fact-find links: anon may only read a link's label and submit to a
- SQL: anon can read a link and submit, never read submissions; owner-only otherwise
- a submission maps to the scenario shape with parsed numbers and sane defaults
- empty or hostile payloads degrade to an empty draft, not a crash
- fact-find URLs point at /factfind.html?t=
- hardening: submit_factfind validates shape, serialises the per-link cap, and the tables are column-locked
- a submission whose containers are the wrong type degrades to an empty draft instead of throwing
funds-card-render · 8
Render smoke test for the Funds to complete card and the property-card
- the Funds card shows the waterfall for the cheapest passing lender
- the card explains itself when it cannot compute
- first-home relief and the grant surface as tags and a credit line
- over the lender cap is called out, and a gift shows the genuine-savings line
- the property card offers state and kind, and first-home / pensioner only to owner-occupiers
- the section is registered in renderAll so it repaints with the form
- a family guarantee that removes LMI shows as waived, with the LVR the lender actually assesses
- changing the loan purpose repaints the property card so the construction fields appear and disappear with it
funds-position · 9
fundsPosition() is the one composer the engine, the form card, the results
- the waterfall adds up: deposit + costs − grant = cash to complete
- duty splits into full − concession + surcharge
- LMI only applies above 80% LVR and is flagged
- the lender LVR cap is reported, not enforced — the row stays comparable
- the grant lands only for a first-home buyer of a new build
- a gift reduces genuine savings but never the cash needed
- a family guarantee is assessed as security: LMI is waived at ≤ 80% effective LVR, reduced when part-way, and the cap gate uses the same LVR
- an unknown state degrades to fees-free duty-free output, flagged stateKnown=false
- investors pay full duty — no first-home relief even if the flag is set upstream
guide-claims · 7
The Serviceability explained guide, the glossary and the calculator's HEM
- the HEM paragraph: how many lenders use which multiplier, and how many a guide confirms
- section 9: the spread across the open panel, and what moves it, on the homepage sample at NAB
- section 9: the floor rates, counted over the lenders accepting broker business
- the retired claims stay retired
- the calculator\'s HEM note counts the panel it is showing
- section 6: the card factor, counted over the lenders accepting broker business
- section 8: the worked example is what the engine computes at CBA
hem · 5
HEM 2025 Q4 — pinned to the workbook cells it was transcribed from, plus
- spot cells match the Bankwest workbook (Maintenance!C7:P19)
- every row is non-decreasing in income, and dependants only add
- hemAnnual picks the right cell and labels the household
- three adults add the couple-minus-single margin, never less than a couple
- postcodes classify into HEM regions per the Auswide table, unknown stays national
hgs-audit-methodology · 5
Home Guarantee Scheme eligibility and its effect on funds to complete;
- Home Guarantee: caps by state and region, first-home owner-occupiers only, 5% minimum
- with the guarantee applied, LMI is waived in funds to complete and flagged
- the audit pack captures inputs, panel data and per-lender provenance
- the methodology page is generated from the tests and overlays, and is current
- the methodology workbook table scrolls inside a labelled, focusable, positioned region — the page never scrolls sideways at 375px
homepage-claims · 7
The homepage and the login page used to carry invented numbers — "1,247
- the panel figures on the homepage match the data file
- the verified-lender count in the homepage copy is the number of overlays that actually cite a workbook
- the login page carries the same figures
- the sample scenario shows what the engine actually computes
- the site-wide chrome and copy use the real lender and portal counts, not "40+"
- the marketing top bar wraps below 820px — a 768px tablet must not scroll sideways
- the invented figures and people stay gone
lazy-load · 11
js/lazy-load.js + js/lazy-features.js — the loader that keeps SheetJS,
- LSLazy.script fetches a src once, however many callers ask for it
- LSLazy.need loads its specs in order and carries the SRI hash across
- LSLazy.run ignores a second click while the first load is in flight
- a script that 404s toasts a message naming the host, and the next press retries
- a CDN failure names the CDN so the message is actionable
- every button entry point stays defined so the buttons still render
- a shim hands its arguments to the real function once the module lands
- a shim never installs itself over a module the page loaded eagerly
- the levers shim spends nothing while every lender passes
- the levers shim fetches js/levers.js as soon as a lender fails
- LSLazy.script does not re-inject a script the page already loaded with a static <script src> (the Deals page engine chain)
lender-logo-fallback · 4
Lender and portal favicons fall back to the abbreviation badge through a
- lenderLogoHtml and portalLogoHtml emit no inline event handler for any lender or portal
- a lender with a domain carries the badge and the second-chance URL as data; a lender without one renders the badge itself
- shared.js registers one capture-phase error listener on the document
- dispatching error on a lender image tries the second URL, then swaps in the badge; other images are left alone
lender-overlays · 14
Lender policy overlays: every `verified: true` overlay must say which
- the verified set is what the docs claim
- every verified overlay names its workbook and cells, and lists what it could not verify
- every verified lender whose workbook computes states LITO and the Medicare low-income rule, each cited
- investment income shading: every verified workbook states its factor, cited to a cell; a draft states it only where its guide does
- base rows match the workbook cells the overlays cite
- the 2024-25 tax scale every workbook carries
- lenderProvenance follows the overlays
- the draft set is what the docs claim
- every draft overlay names a public guide on the lender\'s own domain
- every draft cites a section per figure, never a workbook cell, and lists what it could not source
- base rows carry the figures the draft guides state
- a credit card commits 3.8% of its limit per month, not per year
- the panel defaults reach the unsourced rows and stop at the sourced ones
- an unsourced lender is never described as having a policy
lender-why · 9
"Why this result" — js/lender-why.js (2026-09-11). LoanOptions.ai says its
- a part is a reason only at $10 a month or more, at most three, largest first — and existing loans never are
- closed broker channels are not the yardstick, and nothing honest to compare gives null
- the repayment reason says which rule set the rate: buffer, floor or carded tier — and a custom rate says so
- whySource cites the cell, the guide section, "not verified", or "panel default" — in that order
- on the homepage sample every row obeys the rules, and the headline is the row\'s own surplus against the median
- real citations: NAB\'s buffer cell, La Trobe\'s 2% buffer and $15 fee, Bankwest\'s carded table
- overtime and cards: CBA is marked down by a 50% overtime factor its guide never states, and the reason says so
- the drawer section carries the direction in words, marks an unverified citation, and escapes
- wired: the calculator loads lender-why.js before calc-results.js, and the drawer calls the section after Data provenance
lenders-directory · 7
The public lender directory's card list is generated from the panel data
- the committed directory is what the generator renders now, and regenerating is a no-op
- the brand tints are a generated stylesheet, not inline styles: css/lender-marks.css is fresh and linked
- one card and one badge per lender; every count derives from the data files
- each card shows type, headline rate and that lender\'s own rates-as-at date
- the badge is a focusable link to the methodology anchor, and the generator template defines it
- the page names the real panel size everywhere, and the sitemap lists it
- the in-app Lenders page uses the same badge wording and loads the policies file it needs
lenders-hydration · 5
initLenders() — when the panel is hydrated from public.lenders, and when it
- signed out: the lenders table is never asked, because anon may not read it
- signed in: the panel IS hydrated from the table
- demo mode (no supabase at all) asks nothing and keeps the panel
- a getSession that throws is treated as signed out, never as a reason to try
- the guard is in initLenders, not copied into each call site
levers · 3
The levers optimiser: tries each candidate on a clone through the pure
- a borderline deal gets ranked levers that make progress, and the scenario is untouched
- levers that do not apply are not offered
- applyLever changes the live scenario the way the suggestion described
living-expenses · 9
Living expenses: the lender category set, migration of the old eight keys,
- the category set is the 14 lender keys, and each old key maps to exactly one of them
- migrating an old eight-key row keeps its total, sends each key to one category, and drops junk
- amounts entered weekly, fortnightly or annually normalise to a monthly total
- a lender assesses the higher of declared and HEM, and the row says which it used
- the household HEM preview matches the engine at multiplier ×1 and is pending without income
- the import sanitiser accepts old and new shapes, caps each category, and its fallback matches the canonical table
- the Living expenses card: frequency selector, 14 category fields, running total, HEM comparison and the below-HEM flag
- the drawer sentence names the figure used and why
- the audit pack lists the declared categories, the entry frequency, and how many lenders fell back to HEM
lvr-rules · 5
ANZ security / LVR rules, transcribed from its broker workbook. Cases name
- only lenders with a transcribed rule set return one
- standard owner-occupied P&I, new-to-bank, DTI < 6 → 92% incl LMI (rows 188, 192)
- existing customer → 97% incl LMI (row 187); investor with DTI ≥ 6 → 90% (rows 181–186)
- interest-only → 80%, no LMI (rows 157–158); loan above the category LMI limit → 80%
- rural, land, commercial and mining-town security
modal-visible · 4
Every modal must open.
- every file that builds a modal overlay also opens it
- the class is added after the overlay is in the document, not before
- the opacity rule and its opener are both still in the stylesheet
- the four that were broken are fixed, by name
no-gradients · 3
DESIGN.md: "No gradients on any UI element" — flat surfaces; decorative
- no stylesheet an app page loads contains a CSS gradient
- the two calculator surfaces that carried gradients are flat token surfaces with the same text contrast
- the marketing layer keeps exactly one gradient, the decorative grid pattern in marketing-page.css
objectives · 7
Requirements and objectives (2026-09-11) — the gap from the LoanOptions.ai
- factfind.html asks with exactly the shared vocabulary, every box has an id (so the device draft keeps it), and both forms show it
- _impObjectives: the vocabulary only, deduplicated, in its own order; null when there is nothing
- objectivesNotes names only the two mismatches the calculator can see, and never guesses about a split
- a submission carries its objectives onto the loan, through the whitelist, and the JSON importer keeps them
- the inbox says what the client wants, escaped, and says nothing when they ticked nothing
- the Proposed loan card records them: labelled boxes, a timeframe, the mismatch notes, and a toggle saves without repainting the card
- the audit pack records them, or says they were not recorded; the Copy summary carries them
orgs · 8
Teams: the database decides access. These tests pin the SQL's shape and
- scenarios stay private unless shared, and only writers edit shared ones
- nothing here is reachable by anon, and writes go through RPCs
- invitations expire, can be email-bound, and a team keeps one admin
- helpers: roles and invite links
- the post-login ?next= target only ever points at a page on this site
- scenario rows carry the owner and team so the list can render the badge
- a save never re-sends user_id on update: a team BROKER autosaving a shared scenario must not become its owner
- hardening: helper functions are not public, tables are closed to anon and column-locked for members
pack · 10
The deal pack (deal files 1a-ii, C3): one zip per lender — the lender's
- the pack for NAB holds the workbook, the audit PDF, snapshot.json and manifest.txt; the manifest names the lender, scenario id, panel and commit
- a lender without a staged workbook gets no .xlsx and a manifest line saying so; ANZ ships its template labelled unpopulated
- a failing producer names the file and no zip is written; the zip name falls back to the scenario name, then deal-<id8>
- without a zipper the three content files are saved separately through saveBlob, with the multiple-downloads warning
- packDeal with no chosen lender opens the picker (sections, CLOSED rows), then sets chosen_lender + last_pack_at on success only
- packDeal with a chosen lender packs without asking; "Change lender…" to a different lender confirms first, the same lender does not
- a failed producer leaves the deal untouched and the control idle; a failed patch still leaves the pack saved
- when JSZip cannot load the pack falls back to three separate downloads
- the Excel download icon and the audit pack button each call saveBlob exactly once
- the lazy chain: JSZip is SRI-pinned on jsDelivr, every producer load is in the page\'s preload chain, and preloads match the script credentials mode
page-boot · 6
Every `data-on-<Event>="name"` in an HTML page names a function that the
- every page that uses named dispatch loads the dispatcher first
- every data-on- handler names a function that binds to the global object
- no page carries an inline event attribute or an executable inline script
- every js/page-*.js belongs to a page, and every app page boots from one
- the reachability check rejects the arrow-const form that broke Google sign-in
- every served page loads js/beta-banner.js with the right relative path, except the enumerated exceptions
panel-changelog · 6
The panel changelog is generated from git history. These tests fail when
- the changelog has real history in it and reads oldest-to-newest correctly
- the committed JSON matches what the history generates now
- the homepage card shows the latest three entries
- a dollar figure in an entry survives being written into the homepage card
- the public page, the RSS feed and the sitemap carry the latest entry
- the blog feed no longer credits an editorial team
panel-defaults · 7
PANEL_DEFAULTS — the constant cannot drift from its own evidence.
- the panel splits into sourced and unsourced exactly as the defaults block claims
- each default equals the median of the sourced lenders, computed now
- for all four fields the median is also the mode — so the choice is not a knife edge
- every unsourced lender carries the default, and no sourced lender was tidied to it
- the basis string derives its count and its date — no page hardcodes either
- lenderProvenance carries the basis for generic lenders and withholds it from sourced ones
- the four surfaces that show a generic lender figure say it is a panel default
panel-provenance · 9
The lender panel has one date — PANEL_META.ratesAsAt — and everything that
- PANEL_META.ratesAsAt is a date and matches the header comment
- the committed seed SQL is exactly what the JS generates
- the marketing pages quote the same date
- lenderProvenance reports verified / draft / generic from the policy overlays
- a per-lender re-check date wins over the panel date, and the DB column wins over both
- panelAge counts whole calendar days, is leap-safe, and turns stale after 60 days
- panelAgeNotice is one sentence — derived date, derived count, no "live" — and empty while fresh
- the results header shows the stale notice only when the panel is past 60 days
- the podium view, the drawer and the audit pack carry the same sentence, styled with the amber token
payslip-read · 14
Reading a payslip into a job (js/payslip-read.js).
- dates are read day-first, at local midnight, and an impossible one is refused
- months of the financial year, to the day
- a Xero-style table flattened to one line
- an MYOB-style payslip: a stated salary, an allowance, and YTD under its own label
- an upper-case weekly layout, where YTD follows the gross with no label of its own
- "YTD Gross" is never read as this pay
- a bonus inside year-to-date is taken out before it is annualised
- early in the financial year, this pay is suggested over a thin YTD
- what it cannot read, it leaves blank and says so
- an evidence snippet shown on screen never shows a TFN
- Apply writes only what the choice sets, in the engine\'s own job fields
- the result names the file, shows each figure beside its line, and escapes all of it
- a scan is refused with the reason, before anything is parsed
- wired where a broker types income, and loaded only when pressed
pdf-report · 6
The client PDF report is built from a pure model (buildPdfReportModel) so
- 01 FHB single: first names only, HEM basis on every row, funds for every lender, policy basis and the panel date
- 02 FHB couple + 2 deps: the household behind the HEM line, both first names
- 06 construction split loan: the Structure block carries land + build and the interest estimate; the strip names the split
- bridging + guarantee + gift + deposit bond + fixed + self-employed + declared-below-HEM: every new field is on the model
- a declared figure above HEM is used as declared, and the sentence says every lender used it
- the report renders to a real PDF with jsPDF + autotable
phase0-parity · 4
Phase 0 — the enumerated gate (docs/designs/broker-crm-deal-files.md,
- ${id} × ${file}: every figure matches or carries a cited residual
- Phase 0 tally: zero unexplained residuals across the nine workbooks; the explained set is exactly the enumerated one
- every residual entry names a compared figure, a valid kind, a sentence and a real cell of its workbook
- interest-only: the engine and the workbook mapping assume the same IO term, and both assess P&I over the remainder
policy-qa · 9
Policy Q&A: the model sees panel data and a question — nothing else. These
- the context carries policy, provenance and status — and nothing cosmetic or personal
- the whole-panel context fits itself to the budget, with headroom under the cap
- a per-lender context is small, and carries that lender own citations untrimmed
- a focused context still summarises the rest of the panel — no citations, no detail
- more lenders than the focus limit falls back to full detail for just those
- the SSE parser extracts text deltas and ignores everything else
- the key table is locked: api_key never granted, writes only by RPC, nothing for anon
- the Edge Function is BYOK-only and validates what the browser sends
- hardening: the BYOK key and model are length/shape-capped, and the Gemini key never enters a URL
probe-thresholds · 8
The threshold prober: is the search sound, and does every probed figure in
- bisect finds a synthetic monotonic flip exactly, and repeats itself
- a reading that does not respond reports not probeable, never a number
- bisect rejects a bracket or tolerance that cannot converge
- every probe entry is either a probe or a stated reason, and covers the unverified list
- every probed field the overlays now claim is cited as probed, and the probe agrees with the overlay
- ${lender}.${field}: the workbook still flips where the citation says
- a probe is repeatable: the same lender and field give the same answer twice
- a probe pointed at a cell that cannot respond reports the reason, not a number
rate-review · 7
Rate review sheet: a human-in-the-loop refresh, because the majors render
- candidate extraction picks the lowest variable figure near a "variable" mention, in a sane range
- the review sheet has every lender, the current rate, an as-at date and a page or a note
- a fixed-term label needs the term as well as the word "fixed", in either order
- the fixed sheet is one row per lender per term, with rate left empty to fill
- a fixed sheet merges what it fills, withdraws a lender it blanks, and never touches one it does not mention
- a partial refresh keeps the per-lender date of every lender it did not touch
- the deploy config serves the public rate sheet and withholds the fixed one
rental-tax · 11
Rental income is taxed as income, the way the workbooks do (2026-09-03).
- the panel default is what the three workbooks that take rent do: taxed as income, no expense line, negative gearing on the tax base
- $40k rent at 80%: $32,000 joins the taxable income, the tax on it is $12,480 ($1,040 a month), and nothing is added after tax
- HELP is worked on the employment income only — rent does not raise the repayment (Bankwest CA105)
- negative gearing: interest on the investment loan comes off the tax base only, a loss offsets the salary, and the assessable rent is unchanged
- an existing investment loan with no rate entered is taken at the lender\'s variable rate and flagged
- Bankwest nets the higher of declared costs and 10% of gross rent, floored at nil (CD232 / CD233)
- a lender flagged post-tax keeps the old behaviour: shaded net rent after tax, no negative gearing
- under SMSF the rent is the fund\'s income: nothing joins the member\'s tax
- the homepage sample carries no rent, so nothing here can move it; the row shape is stable for the surfaces that read it
- the drawer and the audit pack read the row, and the provenance carries the derived basis for lenders that do not cite one
- the engine and the workbook mappings agree on what is pushed: La Trobe C43 takes the engine\'s interest, NAB flags the investment loan, Bankwest writes the deductible share
results-layout · 1
The lender comparison table is ~1,270px of column min-widths inside a card
- the results card scrolls sideways rather than clipping the columns a narrow pane cannot fit
results-sort · 7
The results table's column sort. sortResults() (calc-engine.js) reads the
- the Lender column sorts by name, A→Z by default and Z→A on the second click, case-insensitively
- the numeric columns keep their natural default order and flip cleanly
- every sortable results header contains a <button type="button">; the static headers contain none
- aria-sort reflects the active column and direction, and the marker follows it
- focus returns to the active header button after a sort — the re-render replaced the one that was pressed
- setSort toggles direction on a repeat activation and resets it on a new column; the marker reflects direction
- the podium headers hold the same button and aria-sort on both pages, and the renderer keeps aria-sort in step with the sort
scenario-export · 4
The Export button writes the loaded scenario to a .lendscope.json file.
- with no argument the loaded scenario is exported under its own name
- a click event (what data-on-Click passes) is not a snapshot — the loaded scenario is exported, not the event
- an explicit snapshot is exported as given
- both Export buttons still go through the named dispatcher, so the guard above is what protects them
scenario-recheck · 13
Scheduled scenario re-check: the function loads the SAME engine files the
- the function loads the calculator\'s own engine files, in the harness order
- the sandbox technique evaluates the classic scripts and computeResults works on a saved row
- the email counts lenders a broker can lodge with, names the closed ones as excluded, and still diffs them
- the SQL keeps summaries owner-visible only and schedules nightly
- the engine the function evaluates is pinned by hash, and the committed hashes match the files
- scenario-recheck persists movements with two upserts and never diffs a row edited since the last run
- the changelog on disk has the shape the function assumes: no ids, and a grouped entry names no lender
- an edited row reports a movement when the changelog names that lender, and nothing when it does not
- a grouped rates entry names no lender, so it is carried as panel-wide rather than dropped
- an unedited row is unaffected: the whole diff stands, labelled full
- a missing or malformed changelog falls back to suppression and says so in the run note
- the run note reports full, changelog-diffed, suppressed and the lenders the changelog contributed
- the persisted movement keeps id/from/to, so rows written before this change still render
share-links · 18
The share-links panel (js/share-links.js) — the surface that lets a broker
- live means neither revoked nor past its date
- the expiry reads in days, then hours, and never as a negative
- "not opened yet" is a first-class answer, not a zero
- the row escapes the title and carries the token on the button
- an untitled share still names itself
- the panel is wired into the Deals page and nowhere else
- revoking is confirmed before it happens, and the copy says it is one-way
- the panel renders nothing when the broker has no live links
- share_revoked is a real event kind on both sides
- the panel writes no inline styles and no hex colours
- the scenario filter is applied in SQL, not after the row limit
- backing out of the confirm revokes nothing and reports nothing
- a successful revoke logs the history line and names what happened
- the RPC answering false reads as "already closed", not as success or failure
- an unlinked share still revokes — the history line is the optional half
- a failed revoke reports it and reaches the error sink
- the share modal shows this deal\'s live links before offering another
- share-links.js loads before share.js on the calculator
share-snapshot · 6
A share link is for a client or a referrer, not for a credit file. The
- the snapshot keeps the maths
- the snapshot drops everything that identifies a person beyond a first name
- an unnamed applicant gets a positional label, and the input is not mutated
- share URLs point at /share.html?t= on the current origin
- the SQL lets anon read a share and nothing else
- hardening: the share RPC caps and whitelists what anon readers will see, and the table is closed to clients
specialist-match · 16
Specialist lender matcher: every figure it asserts must carry a source of
- there is data to match on
- every figure on every tier is a cited figure — no uncited numbers survive
- no figure ships with kind "unsourced" — unsourceable claims were deleted, not caveated
- every tier names a published source document, on the lender\'s own domain
- the three lenders whose policy could not be sourced are dropped from matching and named as not published
- claims sweep: the retired category-level assertions are gone
- La Trobe carries no invented credit thresholds — its guide publishes a methodology, not figures
- matchSpecialistLenders is pure and deterministic
- a clean 80% full-doc client fits several tiers, and every reason it gives is cited
- published thresholds actually gate — Bluestone Prime refuses a discharged bankrupt, Specialist takes one
- an LVR over every published cap returns an honest empty state, not a nearest guess
- a tier is never offered for a credit event its guide does not mention
- La Trobe reports refer, never a pass or a fail, on a credit question
- the renderer shows the provenance and links the source document
- the renderer escapes — a source ref cannot inject markup
- the conditional credit inputs are toggled by the renderer, not left visible
sql-split · 2
The real-Postgres suite splits SQL files exactly the way scripts/apply-sql.py does.
- the JS splitter agrees with scripts/apply-sql.py on every supabase/*.sql
- the splitter handles the shapes the files rely on
statement-read · 11
Living expenses from a bank-statement export (js/statement-read.js).
- amounts in the shapes banks write them
- dates day-first at local midnight, and an impossible one refused
- exclusions win over categories, and the order of the rules is the policy
- the columns of each major\'s export
- separate Debit and Credit columns become one signed amount
- THREE MONTHS OF A HOUSEHOLD: averaged per month, exclusions shown, nothing lost
- notes: a short statement, gambling, buy-now-pay-later
- the result shows the mapping, the months, the exclusions, and escapes all of it
- a merchant example never shows a TFN
- a real CSV through the SheetJS the page loads
- wired on the living-expenses card, loaded only when pressed, and never a PDF
structures · 7
Bridging, construction, company/trust borrowers and deposit bonds — each
- bridging services the end debt and gates on peak LVR across both securities
- peak debt over 80% of both securities fails the LVR gate even when end debt is fine
- construction: duty on the land, FHOG as a new build, interest during the build estimated
- a company or trust borrower is assessed as self-employed under the lender method
- a deposit bond adds its fee to funds to complete and is labelled
- imports round-trip the new fields through the sanitisers
- a new loan split takes its purpose from the security property, not from the transaction type
tax-offsets · 6
Tax offsets and levies per lender: the ATO LITO schedule, the Medicare
- the ATO low income tax offset: $700 to $37,500, 5c taper to $45,000, 1.5c taper to nil at $66,667
- Medicare levy shapes: flat, 10c phase-in between the thresholds, and the Bankwest cliff
- HELP repayments follow the 2025-26 marginal thresholds and are capped at the balance
- net income at $45k and $60k differs between a LITO lender and a no-LITO lender by exactly the offset
- a $167k applicant is unaffected by the per-lender rules — LITO is nil and Medicare is the full 2% everywhere
- calcTax without rules is the panel default, and matches GENERIC_POLICY
tfn-wiring · 31
Where the TFN guard is actually attached.
- importing a .lendscope.json redacts a TFN and never stores it
- a clean import says nothing about TFNs
- the export redacts on the way OUT, not only on the way in
- the share snapshot redacts — so the deal pack does too
- the CRM import redacts the PARSED ROWS, before the column mapping
- the fact-find redacts before posting — the CLIENT does not know the rule
- a readable file carrying a TFN is refused before a byte is uploaded
- a document the broker classified as a tax return is not scanned, and says why
- a spreadsheet IS scanned, through SheetJS pulled on demand
- a spreadsheet reports NOT scanned when SheetJS cannot be reached
- an oversized spreadsheet is declined rather than freezing the tab
- an unreadable file reports that it was NOT scanned, rather than that it is clean
- a clean readable file passes
- the upload path refuses before it uploads, not after
- a field that is nothing but a TFN is EMPTIED
- a field that CONTAINS a TFN keeps the broker\'s writing and loses the number
- clearing fires input and change, or the old value survives in S
- a legitimate value is untouched and any stale flag is lifted
- clear:false is available for a caller that only wants to mark
- the live watch CLEARS, and the message describes something that works
- tfn.js loads before shared.js on every page that has both
- every guarded call site is typeof-guarded, so a page without tfn.js still works
- the spreadsheet reader is reached lazily and never assumed present
- THE LAZY CONTRACT: need() is called with an array, not a bare spec
- a PDF whose text layer carries a TFN is refused
- a PDF with a clean text layer passes
- A SCAN IS NOT A CLEAN BILL OF HEALTH — no text layer means not scanned
- a long PDF read only in part is not reported clean either
- …but a TFN found inside the pages that WERE read is still refused
- an unreachable PDF reader and an oversized PDF both report not scanned
- pdf.js is pinned to a UMD version and its worker to the same one
tfn · 15
The TFN detector (js/tfn.js).
- the ATO checksum accepts published test TFNs and rejects near misses
- length is 8 or 9 digits, and nothing else counts
- placeholder runs are never treated as TFNs
- the conventional 3-3-3 grouping is recognised
- THE FALSE POSITIVES THAT MATTER: what a broker legitimately types is left alone
- DATES ARE NOT TFNs — the 8-digit trap
- a TFN is not matched out of the middle of a longer number
- findTFNs locates every hit in a sentence, with its position
- redaction replaces the number and keeps the sentence around it
- redaction handles several hits and leaves clean text untouched
- the deep walk redacts anywhere in a scenario shape and names the fields
- the deep walk survives hostile shapes rather than throwing
- a skip predicate can exempt a path
- the notice names what went and says the value can be re-entered
- the detector holds against a run of random 9-digit numbers
topbar-landmark · 5
The app top bar is a banner landmark on every signed-in page. It used to be
- js/nav.js renders the top bar as <header class="ls-topbar" role="banner"> and calculator.html hardcodes the identical element (pitfall #6)
- calculator.html has exactly one banner landmark: the scenario header is a labelled region, not a second <header>
- the chrome is selected by class, never by element, so the div→header change cannot unstyle it or break initSidebar
- the rail avatar button is named "Sign out" by aria-label in both sidebars — its initials are decoration, and title is never the only name
- app pages that inject the sidebar take the banner from initSidebar and hardcode no second topbar
version-diff · 8
Version diff: diffScenarioInputs() walks the scenario input shape and
- changed fields carry human labels and format hints; money is money, percent is percent
- added and removed items in nested arrays produce one row each with a summary
- arrays match by id when both sides have one (reorder is not a change), else by index
- noise fields are ignored and empty-ish values compare equal
- DB-shape version rows (snake_case) diff against app-shape scenarios
- security and linked property ids resolve to "Property N", never raw ids
- formatDiffValue and renderVersionDiffHtml format by hint and escape everything painted
- the modal selects every input column, offers Changes and Compare-with-current, and the page passes the current scenario
versions-and-digest · 7
Scenario version history and the panel email digest: SQL shape, function
- versions are written by trigger on input change and restore respects scenario permissions
- versionSummary reads names, loan and pass count
- the digest: anon can only subscribe/confirm/unsubscribe, the function fails closed, skips without a provider key and mails confirmed addresses only
- double opt-in: the row starts unconfirmed with a 7-day hex token, re-subscribing a live address is a no-op, and the confirm RPC gates its token like unsubscribe
- the panel-subscribe function: public POST, validates and rate-limits before the RPC, one answer for every address, and sends nothing without a provider key
- the changelog page carries the digest form and its script, says confirmation is required, and the client goes through the function
- hardening: the digest RPCs cap volume and token shape, the tables are unreachable from clients, and the cron secret is compared in constant time
workbook-fidelity · 7
The workbook-fidelity harness must load the NAB workbook into HyperFormula
- the harness loads the NAB workbook, covers its functions and most of its names
- ANZ: the VBA functions its formulas call are re-implemented, not missing
- ANZ: the frequency, tax, HEM and repayment functions match the VBA
- ANZ: the SoP income functions match the VBA
- ANZ: INDIRECT is classified and mostly resolved, and the sheets it drove now compute
- ANZ: whole-workbook divergence stays under the measured ceiling
- the VBA extractor reads ANZ\'s modules straight out of the .xlsm
workbook-mode · 12
Workbook mode: the calculator's "Verify with the lender's workbook" button
- workbook-profiles.js loads as a classic script and exposes the nine computing profiles
- the harness imports its profiles, plugin and rewriter from the shared file (one copy)
- scenario → inputs: applicants, dependants, cards, existing loans, property, loan and living expenses land in each workbook\'s cells
- scenario → inputs: second applicant, self-employed income, interest-only, investment security and HEM-mode expenses
- compareOutputs: matches within tolerance, flags differences, and says "did not compute" for a non-numeric readback
- the eight workbooks, driven from the scenario mapper, reproduce the parity figures (engine-vs-workbook.test.mjs)
- ANZ: the mapper drives SoP 1; repayment and net income match, and the living-expense basis is reported as a difference
- re-driving a cached book with a changed scenario updates the workbook (no stale inputs)
- the drawer panel escapes workbook text and formats money, rates, ratios and deltas
- a figure outside tolerance carries its profile residual (or is flagged unexplained), the drawer renders it in the row, and the audit pack prints the identical string
- calculator.html loads the profiles and workbook-mode scripts after SheetJS, and the drawer asks workbook mode for its section
- a verify that outlives the drawer lands in the reopened section, and a second click meanwhile shows the busy state
xlsx-late-icons · 3
The per-row Excel download icons, now that SheetJS and js/xlsx-export.js are
- renderResults paints the table with window.LSXlsx absent — no icons, no throw
- the icons appear on the next paint once window.LSXlsx arrives
- js/xlsx-export.js repaints the results itself when it lands late
xlsx-tables · 8
Structured table references and the Excel-compatibility rewrites applied
- structured references resolve to the ranges Excel uses
- escaped characters in column names are unescaped
- the zip reader finds the eight tables in the Auswide workbook and maps them to sheets
- SheetJS bookFiles (the browser path) yields the same tables as the zip reader
- splitArgs and rewriteCalls respect nesting, strings and sheet names
- excelCompat: SUMIF sum-range resize, blank criterion, INDEX on a single row
- excelCompat: AND / OR ignore text and empty cells given by reference
- excelCompat: a formula that is only a cell reference reads an empty cell as 0
ytd-months · 5
"Months elapsed" on a year-to-date income (js/calc-forms.js, 2026-09-11).
- months of the financial year, inclusive, to the day
- the form and the payslip reader count months the same way
- a YTD job left blank says what it is being divided by, and offers today\'s months
- switching a job to YTD fills the months in, and only when they are blank
- why it mattered: the same payslip, annualised both ways
What LendScope does not claim
- It is not a lender's credit decision. Figures are indicative and every lender's own assessment decides.
- Lenders marked draft or generic are not verified against the lender; the calculator says so on the row.
- HEM is the national 2025 Q4 table; the household region is shown but no regional factor is applied, because no workbook we hold varies by region.
- Where a fixed rate is not on file, the calculator uses the variable rate and labels the row.