Settle
An Australian divorce and de facto property settlement modelling platform, with asset discovery for complex pools. This document describes the system as built in the current repository and designs, without building, the phase two document intelligence layer.
Executive summary
Separating couples describe property settlement as gruesome, lawyer-heavy and expensive. The two most painful tasks are listing every asset and debt, and completing financial disclosure with someone they may no longer trust. Lawyers spend expensive hours on both before any negotiation starts.
Settle is a web application that lets one person, or both people together, build the property pool, record what each side thinks each item is worth, and see immediately what different splits look like in practical terms: who keeps the house, who pays whom, whether a superannuation split is needed. It then shows an indicative court outcome as a range, never a single number, produced by a fixed, published, deterministic method with every adjustment explained. A prominent disclaimer sits on the output itself: this is not a legal service, and a family lawyer should finalise any agreement.
What is built today
- Five bounded contexts in TypeScript following domain-driven design: disclosure, asset discovery, settlement modelling, parenting arrangements and identity, plus a shared kernel (Money in integer cents, Result, ids, dates).
- Per-party claimed values on every pool item. An agreed value is derived only when both claims exist and match exactly. Disagreement is a normal state with its own UI, not an error.
- Solo mode (one person enters estimates for the other) and joint mode (partner invited by email, their own figures overwrite the estimates).
- A four-step settlement engine (
assessSettlement,ENGINE_VERSION 1.0.0) and a split modeller that turns a percentage into allocations, a balancing payment and, where needed, a suggested super split. - Anonymous in-browser modelling with no account. Sign-up is required only to save or to invite a partner. Passwordless auth (magic link or six-digit code) via Supabase, Google optional, no Facebook.
- Mocked asset discovery behind a provider port: ASIC, PPSR, state land titles, ATO super and identity verification, gated by a paid 'discovery' tier and, for registries, by verified identity.
- A Postgres schema (Drizzle, migration
0000_calm_triathlon) with tables prefixed by context, including two phase two tables (ph2_documents,ph2_disclosure_flags) that exist as schema only. - Six deterministic demo scenarios built through the domain constructors.
What is designed but not built Phase two
AI parsing of bank statements, payslips, superannuation statements and tax returns into structured extractions, and a rule-based checker that raises disclosure gaps and inconsistencies for a human to resolve. The AI never sets a value and never decides a split; it only proposes, and a party accepts or dismisses.
The core bet. The free government tool covers simple, amicable cases. Settle's differentiator is the hard part of everyone else's case: finding the assets, dealing with disagreement about their value, and making complex pools (trusts, companies, SMSFs, investment property) legible before a lawyer is engaged.
Positioning versus amica
amica is the government-backed online tool that helps separated couples reach agreement on money and parenting. It is well built, trusted, and suits couples who are largely amicable, know what they own, and have a simple pool. Settle does not compete for those couples; it should send them to amica.
| Dimension | amica (as understood, verify before publishing) | Settle |
|---|---|---|
| Target case | Simple, amicable, both parties willing to engage | Any case, including one party working alone, disagreement about values, and complex pools |
| Asset listing | Manual entry of known assets | Manual entry plus a discovery tier that searches registries for what disclosure missed |
| Disagreement | Requires convergence to proceed | Per-party claimed values; the model runs across both views and reports a dollar range that spans them |
| Solo use | Designed for two participants | Solo mode with estimates for the partner, upgradeable to joint without re-entry |
| Output | Suggested split and agreement document | Practical split scenarios first (who keeps what, balancing payment, super split), then an indicative court range with per-factor explanations |
| Complex structures | Limited | First-class categories for business interests, trust interests, SMSFs, defined benefit super, Division 7A style tax debts |
| Documents | User-entered figures | Phase two parsing of statements, payslips and tax returns, with gap and inconsistency flags |
| Price | Free or low cost | Free core tool, paid discovery tier (pricing placeholder, see Section 9) |
| Legal standing | Not legal advice | Not legal advice; disclaimer attached to every output; lawyer finalises |
Positioning statement
For people separating with anything more than a simple pool, Settle is the place to build the full picture of what you both own, see what a fair split looks like in dollars and decisions, and walk into a lawyer's office with the expensive work already done.
Explicit non-goals
- Settle does not give legal advice, draft consent orders or binding financial agreements, or file anything with the court.
- Settle does not compute child support. The parenting context exposes care and cost percentages so the direction of travel can be explained, and stops there.
- Settle does not use AI to produce numbers. The engine is a published heuristic. Phase two AI extracts facts from documents and proposes; humans decide.
Personas and demo scenarios
Three personas drive the design. Each demo scenario in src/demo/scenarios.ts is a complete MatterSnapshot built through the domain constructors with deterministic ids, so seed data can never drift from the invariants. Demo logins are passwordless; the local provider accepts code 123456.
The solo starter
Has left or is about to leave. Does not trust the other side to engage yet. Wants to know 'roughly where do I stand' before spending money on a lawyer. Uses anonymous mode, enters estimates for the partner.
The cooperative pair
Both willing to participate but disagree on a few values (the house, the shares). Want a shared, honest picture and to narrow the dispute to the items that matter. Use joint mode.
The under-disclosed spouse
Suspects the pool is bigger than presented: a company, a trust, a second super fund. Cannot afford forensic accountants up front. Pays for the discovery tier.
The six demo scenarios
Pool and range figures below were produced by running computeResults against the real scenarios on 16 September 2026. The 'range' column is the indicative percentage for party A.
| Slug | Title and story | Type, mode, tier | Years | Items | Net pool (low to high) | Range A |
|---|---|---|---|---|---|---|
young-couple-no-kids | Short marriage, no children, modest pool. Jess Nguyen (nurse, $98k) and Tom Barker (electrician, $112k). One apartment, two cars, small super. Tom brought in $60k versus Jess's $25k. Three items disputed (both cars, contents). | Marriage, solo, free | 4.8 | 9 | $436,300 to $449,300 | 39% to 49% |
family-primary-carer | Long marriage, three children, one income. Sarah Whitfield (full-time parent, carer, $0) and David Whitfield (mining engineer, $215k). Family home retained by Sarah, large super on David's side. Disputes on the home, BHP shares and one account. Used for the worked example in Section 8. | Marriage, joint, free | 16.8 | 11 | $1,644,000 to $1,743,500 | 64% to 72.5% |
unemployed-partner | De facto, one party recently unemployed. Liam Okafor (retrenched, health concern flagged, $0) and Chloe Martin (pharmacist, $104k). Renting; pool is mostly super and debt including an ATO debt. | De facto, solo, free | 8.7 | 10 | $219,000 to $226,500 | 56% to 69% |
high-income-investors | Two high earners, shares and an investment property. Priya Raman (anaesthetist, $410k, $150k inheritance) and Ben Castellano (software architect, $260k, $180k initial contribution). Disputes over the Footscray property, RSUs, Tesla and art. Priya's name has canned discovery findings. | Marriage, joint, discovery | 12.9 | 12 | $3,160,000 to $3,307,000 | 44.3% to 51.3% |
low-income-renters | Low incomes, renting, two young children. Kayla Brown (casual retail, $38k, primary homemaker) and Jordan Lee (delivery driver, $52k). Week-about care, so no primary carer. Nothing but super, a car loan and buy-now-pay-later balances. Every item agreed. | De facto, joint, free | 7.4 | 8 | $65,400 (no dispute) | 49.5% to 56.5% |
family-trust-company | Complex pool: family trust, company, SMSF. Elena Delfino (part-time bookkeeper, $45k, primary homemaker) and Marcus Delfino (company director, $320k, $90k initial contribution). Elena values the company at $1.4m, Marcus at $620k. The trust has only Elena's figure (awaiting other). Marcus's name returns rich mock findings across ASIC, PPSR, land titles and ATO super. | Marriage, solo, discovery | 20.0 | 12 | $4,517,000 to $5,637,000 | 56.2% to 69.7% |
Demo accounts: demo.jess@example.com, demo.tom@example.com, demo.sarah@example.com, demo.david@example.com, demo.liam@example.com, demo.chloe@example.com, demo.priya@example.com, demo.ben@example.com, demo.kayla@example.com, demo.jordan@example.com, demo.elena@example.com, demo.marcus@example.com. The /demo page lists scenarios via GET /api/demo/scenarios and signs in as A or B via POST /api/demo/:slug/login.
Functional scope
Scope is organised by bounded context. Everything marked built exists in src/ today; the UI pages listed in Section 4 render these capabilities.
| Area | Capability | Status |
|---|---|---|
| Disclosure | Create a matter: relationship type (marriage or de facto), cohabitation start, separation date; separation cannot precede start | Built |
| Two parties (roles A and B) with name, date of birth, occupation, employment status, gross annual income (cents), health or capacity flag, primary homemaker flag, initial contribution, inheritances or gifts, participating flag | Built | |
| Children with first name and date of birth | Built | |
| Pool items across 16 categories in three kinds (asset, liability, superannuation), ownership joint, A or B, optional 'retained by', optional discovery reference | Built | |
| Per-party valuations with source (manual, estimate_for_partner, discovery, document); derived agreed value; accept other party's value; agreement state per item; disclosure summary with low and high pool, disputed items ranked by gap, completeness | Built | |
Solo to joint is one-way (inviteToJoint); partner's own figures overwrite estimates | Built | |
| Parenting | Care arrangement per child as nights with A per fortnight (0 to 14) with optional pattern text; care percent, nights per year, Services Australia care level, cost percent table | Built |
| Parenting summary: dependent children (under 18), primary carer (majority care of majority of dependents, null when even), average care percent for A | Built | |
| Settlement modelling | Four-step deterministic assessment with factors, bands, indicative range in percent and dollars, range notes, cautions and on-output disclaimer | Built |
| Split modelling: allocation of each item, party positions, balancing payment, super split suggestion, practical notes; standard scenarios (equal, low, mid, high) | Built | |
| Perspective switch (view the pool as A or B) | Built | |
| Asset discovery | Provider port with five provider codes and metadata (tier and identity requirements); gating function; application service that runs a provider and records a request with findings | Built |
Deterministic mock adapters keyed on subject name; accept a finding to create a pool item with discoveryRef | Built (mock) | |
| Real registry integrations | Later | |
| Identity | Passwordless sign-in (magic link or six-digit code) through an AuthProvider port; local provider for development, Supabase provider for production; Google OAuth; no Facebook | Built |
Sessions as httpOnly cookie settle_session backed by id_sessions | Built | |
| Invitations with 14-day expiry, opposite role offered, email must match on acceptance; matter membership | Built | |
| Tier upgrade and identity verification (mock payment, mock verification) | Built (mock) | |
| Documents | Upload, parse and extract structured data from bank statements, payslips, super statements and tax returns | Phase two |
| Gap and inconsistency detection with human review | Phase two |
User journeys
Journey 1: anonymous solo modelling (free)
- Land on
/, read what the tool does and the disclaimer, press 'Start'. /start: choose marriage or de facto, enter cohabitation start and separation date. An anonymous matter is created in the browser and stored underlocalStorage['settle.matter.v1']./matter/youand/matter/partner: enter your own details and your best estimates for the other person. Estimates are stored under role B withsource: 'estimate_for_partner'./matter/children: add children and the nights-per-fortnight pattern per child, or skip./matter/disclosure: add assets, debts and super. For each item enter your value and your estimate of the other side's value. Agreement chips update as you type./matter/results:computeResultsruns in the browser. Split scenarios are shown first, then the indicative range with the disclaimer attached, then the factor explanations.
Journey 2: save and invite (joint)
- From results or
/matter/share, press 'Save' or 'Invite'. If not signed in, the app sends the person to/sign-inand returns them afterwards. - Sign in by email code or magic link (or Google).
POST /api/mattersstores the snapshot; the creator becomes a member withsnapshot.matter.ownerRole. POST /api/matters/:id/invitewith the partner's email creates an invitation offering the opposite role and moves the matter to joint mode.- The partner opens
/invite/[token], signs in with the invited email, andPOST /api/invitations/:token/acceptadds them toid_matter_members. - The partner reviews every item and enters their own figures, which replace the estimates. Items with matching values become agreed; the others are shown as disputed, ranked by gap.
Journey 3: discovery (paid)
- On
/matter/discovery, providers are listed with lock chips explaining what unlocks them: the discovery tier for all registries, and verified identity for land titles and ATO super. - Upgrade (mock payment) via
POST /api/account/upgrade; verify identity (mock) viaPOST /api/account/verify-identity. - Run a provider for a subject (
POST /api/matters/:id/discovery). Findings appear with confidence (exact, probable, possible) and a suggested item. - Accept a finding; the disclosure context creates a
PoolItemwithdiscoveryRefand the estimated value recorded for the requesting party with sourcediscovery.
flowchart LR
L["Landing /"] --> S["Start: relationship basics"]
S --> Y["You"] --> P["Partner (estimates in solo)"] --> C["Children and care"] --> D["Disclosure: items and per-party values"]
D --> R["Results: splits, then indicative range"]
R -->|save or invite| SI["Sign in (passwordless)"] --> SH["Share: save + invite"]
SH --> INV["Partner accepts /invite/token"] --> D
R -->|deep discovery| DS["Discovery (tier + identity gated)"] -->|accept finding| D
style R fill:#FBE9DA,stroke:#E97819
Information architecture
The App Router page list below is the contract between product and engineering; it matches docs/ARCHITECTURE.md. Every /matter/* page reads and writes the same MatterSnapshot, whether it lives in the browser or in Postgres.
| Route | Purpose | Requires |
|---|---|---|
/ | Landing: what it does, start anonymously, demo scenarios, disclaimer | Nothing |
/start | Relationship basics (type, dates); creates the anonymous matter | Nothing |
/matter/you | Party A details and income | A matter in the browser or a membership |
/matter/partner | Party B details and income (estimates in solo mode) | As above |
/matter/children | Children, ages, care arrangement per child | As above |
/matter/disclosure | Assets, debts, super; per-party values; agreement state per item | As above |
/matter/results | Splits first, then indicative court range with on-output disclaimer, factors | As above |
/matter/discovery | Asset discovery (tier gated, identity gated); findings add to disclosure | Signed in, saved matter |
/matter/share | Save and invite partner | Signed in |
/sign-in | Passwordless (email code or magic link) plus Google | Nothing |
/invite/[token] | Accept an invitation | Signed in as the invited email |
/dashboard | Saved matters | Signed in |
/demo | Pick a demo scenario, sign in as A or B | Nothing |
Navigation model
The matter pages form a linear stepper (you, partner, children, disclosure, results) with a persistent progress bar and free movement between completed steps. Results, discovery and share are always one tap away from the stepper once a matter exists. On mobile the stepper collapses to the current step name plus a 'Steps' drawer.
Persistence states
| State | Where the snapshot lives | Who can see it | Modelling runs |
|---|---|---|---|
| Anonymous | localStorage['settle.matter.v1'] | This browser only | In the browser via computeResults |
| Saved (solo) | Postgres via MatterRepository, browser keeps a working copy | The owner (one membership row) | Browser, or GET /api/matters/:id/results |
| Joint | Postgres | Both members, each with their role | Either side, with perspective chosen |
Wireframes: landing and start
Wireframes are CSS mockups, not final visual design. They fix layout priority, copy hierarchy and the behaviours in Section 4. Navy is structure, terracotta is the one primary action per screen.
List what you both own and owe, see what different splits look like in real terms, and get an indicative court range. No account needed to start.
Assets, debts, super. Your value and theirs.
Who keeps what, who pays whom.
Registry discovery for complex pools.
Three questions. You can change them later.
Separation cannot be before the relationship began.
Wireframes: disclosure list
The disclosure list is the heart of the product. Every row shows both parties' claimed values and an agreement chip derived from agreementState. In solo mode the B column is labelled 'Your estimate for David' and stored with source: 'estimate_for_partner'. In joint mode each person edits only their own column; the other column is read-only with an 'Accept their value' action.
Joint mode. Sarah and David both participating. 8 agreed, 3 disputed, 0 awaiting, 0 unvalued.
| Item | Owner | Sarah (you) | David | Status | Keeps | |
|---|---|---|---|---|---|---|
| Family home, Ashgrove QLD | Joint | 1,450,000 | 1,380,000 gap $70,000 | Disputed | Sarah | Accept theirs |
| Joint savings | Joint | 62,000 | 62,000 | Agreed | Shared | |
| David everyday account | David | 9,000 | 14,500 | Disputed | David | Accept theirs |
| 2021 Kia Carnival | Joint | 38,000 | 38,000 | Agreed | Sarah | |
| Employee share plan (BHP) | David | 95,000 | 71,000 | Disputed | David | Accept theirs |
| Holiday caravan via discovery | David | 18,000 | not yet valued | Awaiting David | ||
| Contents | Joint | no value | no value | Unvalued | Shared |
| Westpac home loan | Joint | 520,000 | 520,000 | Agreed | Sarah (follows home) | |
| Car loan (Hilux) | David | 31,000 | 31,000 | Agreed | David |
| UniSuper (David) | David | 486,000 | 486,000 | Agreed | Member |
ItemAgreementState: agreed (green), disputed (terracotta, never red), awaiting_other (grey), unvalued (dashed). Two rows are illustrative additions to the demo data to show the awaiting and unvalued states.Row behaviours
- Editing your value calls
recordValuation; the agreed value and chip are recomputed immediately and never edited directly. - 'Accept theirs' calls
acceptOtherPartyValue, which records their figure as yours with the note 'Accepted other party value' and therefore produces agreement. - 'Keeps' sets
retainedByand drives the split modeller. Superannuation shows 'Member' and cannot be reassigned here. - Negative values are rejected with 'Enter debts as positive amounts under liabilities'. Remove item is a destructive action and uses red only in its confirmation.
Wireframes: results
Order is fixed by the product brief and enforced in the design: practical split scenarios first, then the indicative range with the disclaimer attached to it, then the factor explanations. Numbers below are the real family-primary-carer output from Section 8.
3 items have different values from each party, so the dollar range spans both views of the pool.
| 1 Identify and value the pool | Net pool $1,644,000 to $1,743,500 across 11 items | |
| 2 Assess contributions | 50% to Sarah (47.5% to 52.5%). Homemaker and parenting contributions offset David's income. | 0 pts |
| 3 Consider future needs | Income disparity (Sarah, carer, no income) +10; care of 3 children incl. one under five +10; capped at 20 | +20 pts |
| 4 Just and equitable check | 64% to 72.5% for Sarah |
Results on a phone
On narrow screens scenarios become a horizontally swipeable card rail with the midpoint card first. The indicative range stacks the two parties. The disclaimer keeps its full text; it is never truncated or collapsed behind a 'read more'.
Swipe. Pool from your view $1,738,000.
3 disputed items widen the dollar range.
Wireframes: discovery, sign-in and share
Search public registries for a person. Findings are leads; nothing enters the pool until you accept it.
PROVIDER_META and gateDiscovery. Findings show confidence and a one-click suggested item.No passwords. We email you a six-digit code or a link.
Your anonymous matter in this browser will be saved to your account.
POST /api/auth/start; the code path then calls /api/auth/verify.David gets role B. Your estimates for him are replaced by his figures as he enters them. Once invited, the matter is shared and cannot go back to solo.
Domain model and bounded contexts
The code is organised as five bounded contexts plus a shared kernel under src/contexts. The rule enforced by convention and review: no context imports another context's infrastructure. Domain types may be imported across contexts where one context is upstream of another; those dependencies are drawn below.
flowchart TB
SK["Shared kernel: Money (cents), Result, ids, dates"]
DC["Disclosure: Matter, Party, Child, PoolItem, Valuation, agreedValue rule, DisclosureSummary"]
PA["Parenting: CareArrangement, care percent, cost percent, primary carer"]
SM["Settlement modelling: assessSettlement, Factor, Band, modelSplit, standardScenarios"]
AD["Asset discovery: Provider port, PROVIDER_META, gateDiscovery, runDiscovery, Finding"]
ID["Identity: User, Session, AuthProvider port, Invitation, membership, Tier"]
RM["Read model: MatterSnapshot, MatterResults, computeResults (published language)"]
SK --> DC
SK --> PA
SK --> SM
SK --> AD
SK --> ID
DC -->|"upstream: Child type (conformist)"| PA
DC -->|"upstream: Matter, Party, PoolItem, summariseDisclosure (conformist)"| SM
PA -->|"upstream: ParentingSummary (conformist)"| SM
DC -->|"upstream: PoolItemCategory (conformist)"| AD
AD -->|"Finding is a lead; user accepts, disclosure creates PoolItem with discoveryRef (anti-corruption at the boundary)"| DC
AD -->|"Tier type (conformist)"| ID
ID -->|"membership gates access to a Matter"| DC
DC --> RM
PA --> RM
SM --> RM
RM -->|"same JSON shape in browser, API and DB"| UI["UI, API route handlers, MatterRepository"]
style RM fill:#FBE9DA,stroke:#E97819
style SK fill:#EAF0F6,stroke:#103654
| Context | Responsibility | Tables |
|---|---|---|
| Shared kernel | Integer-cent Money with splitByPercent that always sums exactly, formatAud, branded ids, PartyRole A or B, ISO dates, Result so domain code never throws for expected failures | None |
| Disclosure (core) | The Matter aggregate and everything disclosed. Owns the agreed value rule and the low and high pool calculation | dc_matters, dc_parties, dc_children, dc_pool_items, dc_pool_item_valuations |
| Parenting | Care pattern per child and derived percentages. Does not compute child support | pa_care_arrangements |
| Settlement modelling | Pure computation. No tables; results are recomputed on demand and versioned by ENGINE_VERSION | None (sm_ reserved) |
| Asset discovery | Registry search behind a port, gating, findings as leads | ad_discovery_requests |
| Identity | Users, sessions, challenges, tier, invitations, matter membership; authentication delegated to AuthProvider | id_users, id_sessions, id_auth_challenges, id_matter_members, id_invitations |
| Documents Phase two | Uploaded documents, extractions, disclosure flags | ph2_documents, ph2_disclosure_flags |
Aggregates, invariants and agreement states
Key invariants (from ARCHITECTURE.md, enforced in code)
- Money is integer cents everywhere in the domain.
recordValuationrejects non-integers and negatives. Formatting happens at the edge withformatAud. PoolItem.valuationsholds one claimed value per party.agreedValueis derived byderiveAgreedValueand is null unless both claims exist and are exactly equal. It is never set by hand; the repository recomputes it on save.- In solo mode the user's estimates for the partner are stored under the partner's role with
source: 'estimate_for_partner'. Joint mode overwrites them with the partner's own figures (sourcemanual). - The settlement engine is pure and deterministic. Every adjustment is a
Factorwith an explanation. The output carries adisclaimerstring the UI must render on the result itself. - Court outcome is always a range. Dollar ranges span both parties' views of the pool.
- Moving from solo to joint is one-way (
inviteToJointrejects an already-joint matter). - Separation date cannot precede cohabitation start. Nights per fortnight are 0 to 14. Descriptions and names cannot be blank.
Effective value and the two pools
effectiveValue(item, perspective) returns the agreed value first, then the viewer's own claim, then the other party's claim. summariseDisclosure also computes a low pool (for each item the value least favourable to a high total: the smaller asset claim, the larger debt claim) and a high pool (the reverse). The engine's referencePool is the midpoint of the two net totals.
stateDiagram-v2
[*] --> unvalued : createPoolItem
unvalued --> awaiting_other : one party records a value
awaiting_other --> agreed : other party records the same value
awaiting_other --> disputed : other party records a different value
awaiting_other --> awaiting_other : same party revises value
disputed --> agreed : either party revises to match, or acceptOtherPartyValue
agreed --> disputed : either party revises to a different value
disputed --> disputed : revise, still different
agreed --> agreed : both revise to the same new value
note right of disputed
Normal state. Shown with a terracotta chip,
ranked by valuationGap on the disclosure page,
widens the dollar range on results.
end note
note right of agreed
agreedValue is set and equals both claims.
Derived by deriveAgreedValue, never edited.
end note
agreementState. Removing a valuation is not supported today; a value can only be replaced.Ubiquitous language
Terms below are used identically in code, UI copy, this document and conversations with the client. Where the UI shows a friendlier label, it is noted.
| Term (code) | Meaning |
|---|---|
Matter (Matter) | The shared container for one separating couple: mode, status, relationship type, dates, owner role. |
Party, role (Party, PartyRole) | One of the two people, always A or B. Roles are stable across the whole system; the creator is usually A (ownerRole). |
Mode (MatterMode) | solo: one person enters everything including estimates for the other. joint: both are members and enter their own figures. |
Status (MatterStatus) | draft, disclosing, modelling, agreed, archived. Informational today; drives dashboard labels. |
Pool item (PoolItem) | One asset, liability or superannuation interest. UI: 'item'. Has a kind (derived from category), ownership and optional retained-by. |
Claimed value (Valuation) | What one party says an item is worth, with source, note and timestamp. UI: 'your value', 'their value', 'your estimate for X'. |
Agreed value (agreedValue) | Derived. Present only when both claims exist and match. |
Agreement state (ItemAgreementState) | agreed, disputed, awaiting_other, unvalued. UI chips. |
Gap (valuationGap) | Absolute difference between the two claims; zero unless both present. |
Effective value (effectiveValue) | The value used from one perspective: agreed, else own, else other's. |
Low pool, high pool (DisclosureSummary.low/high) | Net pools built from the least and most favourable claims per item. The dollar range on results spans them. |
Reference pool (referencePool) | Midpoint of low and high net totals; used to express contribution differences as a percentage of the pool. |
Factor (Factor) | One adjustment with code, group (contributions or future needs), label, legislative basis, plain-English explanation and points toward A as a band. |
Band (Band) | A low, mid and high number. Settlements are ranges, not points. |
Indicative range (indicativeRange) | Percent low and high for each party plus dollar low and high. UI: 'Indicative court range'. Never shown as one number. |
Split scenario (SplitScenario) | A percentage turned into allocations, positions, a balancing payment and possibly a super split suggestion. UI: 'What different splits look like'. |
| Balancing payment | Cash or asset transfer so retained items land on the target split. |
| Super split suggestion | Amount of superannuation that would need to move by splitting order when the payer cannot fund the balance outside super. |
Care arrangement (CareArrangement) | Nights per fortnight a child spends with A. Care percent and Services Australia care level are derived. |
| Primary carer | The party with majority care of the majority of dependent children; null when even. |
Provider (DiscoveryProvider, ProviderCode) | A registry behind a port: asic, ppsr, land_titles, ato_super, identity. |
| Finding | A lead from a provider with confidence and an optional suggested item. Never enters the pool without a person accepting it. |
| Discovery request | One run of one provider for one subject, with status and findings. |
| Tier | free or discovery. Attached to a user, not a matter. |
| Invitation, member | An emailed offer of the opposite role; accepting creates a matter membership row. |
Snapshot (MatterSnapshot) | The whole matter as one JSON aggregate, version 1. The published language between browser, API and database. |
Results (MatterResults) | An assessment plus the standard scenarios, from one perspective. |
Architecture: system context
A C4 level-one view. The people, the one system, and the external systems it depends on. Registry adapters are mocked today and are drawn where the real services will sit.
flowchart LR
subgraph people["People"]
A["Party A: the person who starts the matter"]
B["Party B: the former partner, invited later"]
LW["Family lawyer: receives the summary, finalises the agreement"]
end
subgraph sys["Settle (working title)"]
S["Web application: Next.js on Vercel, modelling in browser and server"]
end
subgraph ext["External systems"]
SBA["Supabase Auth: magic link, email OTP, Google"]
PG["Postgres: Supabase in production, local in development"]
ASIC["ASIC registry (mocked)"]
PPSR["PPSR (mocked)"]
LT["State land title registries (mocked)"]
ATO["ATO super via myGov consent (mocked)"]
IDV["Identity verification vendor (mocked)"]
LLM["Document extraction model (phase two)"]
end
A -->|"models anonymously, saves, invites"| S
B -->|"accepts invitation, enters own values"| S
S -->|"exported summary (planned)"| LW
S -->|"passwordless sign-in"| SBA
S -->|"Drizzle over pg"| PG
S --> ASIC
S --> PPSR
S --> LT
S --> ATO
S --> IDV
S -.->|"phase two"| LLM
style S fill:#103654,stroke:#103654,color:#fff
style LLM stroke-dasharray: 5 5
Architectural principles
- Same shape everywhere. The
MatterSnapshotJSON is what the browser stores, what the API sends, and what the repository normalises into rows and back. Anonymous to saved needs no conversion. - Compute where the data is. The engine runs in the browser for anonymous users and in route handlers for saved matters. It is the same pure function.
- Ports for everything external.
AuthProviderandDiscoveryProviderare interfaces with local and mock implementations; production adapters are drop-ins. - Whole-aggregate persistence.
MatterRepository.savereplaces the aggregate in one transaction. Simple and correct at tens of items per matter.
Components and deployment
flowchart TB
subgraph browser["Browser"]
direction LR
UI["React 19 pages"]
ENG["computeResults"]
LS["localStorage settle.matter.v1"]
UI --> ENG
UI --> LS
end
subgraph vercel["Vercel: one Next.js deployment"]
direction LR
RH["Route handlers /api/*"]
AUTH["AuthProvider port"]
REPO["MatterRepository (Drizzle, pg Pool)"]
DISC["runDiscovery + gateDiscovery"]
RH --> AUTH
RH --> REPO
RH --> DISC
end
subgraph data["Supabase project or local Postgres"]
direction LR
DB[("Postgres: id_ dc_ pa_ ad_ ph2_")]
SBA["Supabase Auth"]
STO["Storage bucket (phase two)"]
end
subgraph adapters["Registry adapters"]
direction LR
MOCK["MockProvider x4 + mockVerifyIdentity"]
REAL["Real ASIC, PPSR, titles, ATO, KYC (later)"]
end
subgraph ph2["Phase two worker"]
direction LR
Q["Queue"] --> W["Parsing worker"] --> CHK["Disclosure checker"]
end
UI -->|"JSON over HTTPS, settle_session cookie"| RH
DISC --> MOCK
DISC -.-> REAL
MOCK ~~~ Q
REAL ~~~ Q
AUTH --> SBA
REPO --> DB
RH -.-> STO
STO -.-> W
CHK -.-> DB
style ph2 stroke-dasharray: 5 5
style REAL stroke-dasharray: 5 5
style STO stroke-dasharray: 5 5
| Concern | Decision |
|---|---|
| Runtime | Next.js 16 App Router, React 19, TypeScript 5, Node 20+ (crypto.randomUUID in browser and server). Tailwind 4 for styling. |
| Hosting | Vercel. Route handlers run as serverless functions; a lazily created pg.Pool (max 10) is shared per process. |
| Database | Postgres. Supabase in production, local settle_dev in development. Identical DDL from Drizzle migrations. Connection via DATABASE_URL; on Supabase use the pooled (transaction mode) connection string. |
| Auth | isSupabaseEnabled() is true only when both public Supabase variables exist; otherwise LocalAuthProvider with fixed code 123456 and a local Google stand-in route. |
| Sessions | App-owned: id_sessions.token in an httpOnly settle_session cookie. Supabase Auth proves the email; Settle issues its own session so local and production behave the same. |
| Config | src/lib/server/env.ts is the only reader of process.env on the server. |
| Observability | Vercel logs and Supabase logs today. Structured logging of API errors { code, message }. No analytics vendor is included; see privacy. |
Sequence: anonymous, save, invite, accept
sequenceDiagram
autonumber
actor A as Party A (browser)
participant LS as localStorage
participant API as Next route handlers
participant AUTH as AuthProvider
participant DB as Postgres
actor B as Party B (browser)
A->>LS: create matter, parties, items (settle.matter.v1)
A->>A: computeResults in browser
A->>API: POST /api/auth/start { email, method }
API->>AUTH: startEmailSignIn
AUTH-->>A: email with code or magic link
A->>API: POST /api/auth/verify { challengeId, code }
API->>AUTH: verifyEmailCode
API->>DB: upsert id_users, insert id_sessions
API-->>A: { user } + Set-Cookie settle_session
A->>API: POST /api/matters { snapshot }
API->>DB: MatterRepository.save (one transaction), insert id_matter_members role = ownerRole
API-->>A: { matterId, role }
A->>API: POST /api/matters/:id/invite { email }
API->>DB: insert id_invitations (roleOffered = other role, 14 days), dc_matters.mode = joint
API-->>A: { invitation, acceptUrl }
A-->>B: shares link /invite/[token] (email in production)
B->>API: sign in with the invited email (same auth steps)
B->>API: POST /api/invitations/:token/accept
API->>DB: check pending, not expired, email matches, insert id_matter_members role B, invitation accepted
API-->>B: { matterId, role: B }
B->>API: GET /api/matters/:id
API-->>B: { snapshot, role }
B->>API: PUT /api/matters/:id { snapshot with B valuations, source manual }
API->>DB: save, recompute agreed_value per item
Step 17 uses acceptUrl for local demos. In production the invitation email carries the link and the token is never shown to the inviter.
Data model
The schema is defined in src/db/schema.ts with Drizzle and generated into drizzle/0000_calm_triathlon.sql. Table prefixes make context ownership visible in the database: id_ identity, dc_ disclosure, pa_ parenting, ad_ asset discovery, sm_ reserved (no tables, pure computation), ph2_ phase two. Money columns are bigint cents. All timestamps are timestamptz.
erDiagram
id_users {
uuid id PK
text email UK
text display_name
tier tier
boolean identity_verified
text auth_subject
}
id_sessions {
text token PK
uuid user_id FK
timestamptz expires_at
}
id_auth_challenges {
uuid id PK
text email
text method
text code_hash
}
id_matter_members {
uuid matter_id PK
uuid user_id PK
party_role role
}
id_invitations {
uuid id PK
uuid matter_id FK
uuid invited_by_user_id FK
text invited_email
party_role role_offered
text token UK
invitation_status status
timestamptz expires_at
uuid accepted_by_user_id FK
}
dc_matters {
uuid id PK
matter_mode mode
matter_status status
relationship_type relationship_type
date cohabitation_start
date separation_date
party_role owner_role
}
dc_parties {
uuid id PK
uuid matter_id FK
party_role role
text display_name
date date_of_birth
text occupation
employment_status employment_status
bigint annual_income
boolean health_or_capacity_concern
boolean primary_homemaker
bigint initial_contribution
bigint inheritances_or_gifts
boolean participating
}
dc_children {
uuid id PK
uuid matter_id FK
text first_name
date date_of_birth
}
dc_pool_items {
uuid id PK
uuid matter_id FK
pool_item_kind kind
text category
text description
ownership ownership
bigint agreed_value
party_role retained_by
text discovery_ref
}
dc_pool_item_valuations {
uuid item_id PK
party_role party_role PK
bigint value
valuation_source source
text note
}
pa_care_arrangements {
uuid child_id PK
uuid matter_id FK
smallint nights_with_a_per_fortnight
text pattern
}
ad_discovery_requests {
uuid id PK
uuid matter_id FK
discovery_provider provider
jsonb subject
party_role requested_by_role
discovery_status status
jsonb findings
text error
}
ph2_documents {
uuid id PK
uuid matter_id FK
party_role uploaded_by_role
document_kind kind
text storage_path
document_status status
jsonb extraction
text parser_version
}
ph2_disclosure_flags {
uuid id PK
uuid matter_id FK
disclosure_flag_kind kind
smallint severity
text message
uuid related_item_id FK
uuid source_document_id FK
disclosure_flag_status status
}
id_users ||--o{ id_sessions : "has"
id_users ||--o{ id_matter_members : "joins"
dc_matters ||--o{ id_matter_members : "has members"
dc_matters ||--o{ id_invitations : "invites to"
id_users ||--o{ id_invitations : "sends"
dc_matters ||--|{ dc_parties : "two parties"
dc_matters ||--o{ dc_children : "has"
dc_matters ||--o{ dc_pool_items : "pool"
dc_pool_items ||--o{ dc_pool_item_valuations : "claimed per party"
dc_children ||--o| pa_care_arrangements : "care"
dc_matters ||--o{ ad_discovery_requests : "runs"
dc_matters ||--o{ ph2_documents : "uploads"
dc_matters ||--o{ ph2_disclosure_flags : "flags"
dc_pool_items o|--o{ ph2_disclosure_flags : "relates to"
ph2_documents o|--o{ ph2_disclosure_flags : "sourced from"
matter_id cascades on matter delete, which is what makes 'delete my matter' complete.Schema: identity (id_)
Enum types created by the migration: tier (free, discovery), party_role (A, B), invitation_status (pending, accepted, expired, revoked).
id_users
| Column | Type | Constraints | Why it exists |
|---|---|---|---|
| id | uuid | PK, default gen_random_uuid() | App-owned user id, stable across auth providers |
| text | NOT NULL, unique index id_users_email_idx | Passwordless identity; lowercased before storage | |
| display_name | text | nullable | Optional friendly name |
| tier | tier | NOT NULL default 'free' | Gates discovery providers |
| identity_verified | boolean | NOT NULL default false | Gates land titles and ATO super |
| auth_subject | text | nullable | Supabase auth.users.id when on Supabase; null for local auth. Basis for RLS mapping |
| created_at | timestamptz | NOT NULL default now() | Audit |
id_sessions
| Column | Type | Constraints | Why it exists |
|---|---|---|---|
| token | text | PK | Opaque value stored in the settle_session httpOnly cookie |
| user_id | uuid | NOT NULL, FK id_users ON DELETE CASCADE | Deleting a user ends their sessions |
| expires_at | timestamptz | NOT NULL | Server-side expiry; checked on every request |
| created_at | timestamptz | NOT NULL default now() | Audit |
id_auth_challenges
| Column | Type | Constraints | Why it exists |
|---|---|---|---|
| id | uuid | PK default gen_random_uuid() | The challengeId returned by /api/auth/start in local mode |
| text | NOT NULL | Who is signing in | |
| method | text | NOT NULL | 'magic_link' or 'email_otp' |
| code_hash | text | NOT NULL | Codes are never stored in clear |
| created_at | timestamptz | NOT NULL default now() | 10-minute expiry window |
| consumed_at | timestamptz | nullable | Single use |
id_matter_members
| Column | Type | Constraints | Why it exists |
|---|---|---|---|
| matter_id | uuid | PK (with user_id), FK dc_matters CASCADE | Which matter |
| user_id | uuid | PK (with matter_id), FK id_users CASCADE | Which user |
| role | party_role | NOT NULL, unique index id_matter_members_role_idx (matter_id, role) | A user occupies exactly one side; no two users can hold the same role on one matter |
| joined_at | timestamptz | NOT NULL default now() | Audit |
id_invitations
| Column | Type | Constraints | Why it exists |
|---|---|---|---|
| id | uuid | PK | Identity |
| matter_id | uuid | NOT NULL, FK dc_matters CASCADE | Target matter |
| invited_by_user_id | uuid | NOT NULL, FK id_users | Inviter, for audit and revocation rights |
| invited_email | text | NOT NULL | Lowercased; must match the accepting user's email |
| role_offered | party_role | NOT NULL | Always the opposite of the inviter's role |
| token | text | NOT NULL, unique index id_invitations_token_idx | Unguessable UUID in the accept URL |
| status | invitation_status | NOT NULL default 'pending' | Lifecycle |
| created_at, expires_at | timestamptz | NOT NULL | 14-day TTL by default |
| accepted_by_user_id | uuid | nullable, FK id_users | Who accepted |
Schema: disclosure (dc_)
Enums: matter_mode, matter_status, relationship_type, employment_status, pool_item_kind, ownership, valuation_source. Category is stored as text (16 values in KIND_BY_CATEGORY) so new categories do not need a migration.
dc_matters
| Column | Type | Constraints | Why it exists |
|---|---|---|---|
| id | uuid | PK | Matter id; browser-generated UUIDs are accepted on first save |
| mode | matter_mode | NOT NULL default 'solo' | Solo or joint; one-way |
| status | matter_status | NOT NULL default 'draft' | Dashboard label |
| relationship_type | relationship_type | NOT NULL | Marriage (s79) or de facto (s90SM) |
| cohabitation_start, separation_date | date | NOT NULL | Relationship length drives initial contribution erosion and the short-relationship note |
| owner_role | party_role | NOT NULL default 'A' | Role the creator occupies |
| created_at, updated_at | timestamptz | NOT NULL default now() | Dashboard ordering |
dc_parties (unique index dc_parties_matter_role_idx on matter_id, role)
| Column | Type | Constraints | Why it exists |
|---|---|---|---|
| id, matter_id, role | uuid, uuid, party_role | PK; FK CASCADE; NOT NULL | Exactly one row per role per matter |
| display_name | text | NOT NULL | Used in every explanation string |
| date_of_birth | date | nullable | Age gap factor (only when both present) |
| occupation | text | nullable | Context for the lawyer summary |
| employment_status | employment_status | NOT NULL default 'employed' | Zero income plus unemployed or carer triggers the strongest income factor |
| annual_income | bigint | NOT NULL default 0 | Gross annual income in cents |
| health_or_capacity_concern | boolean | NOT NULL default false | Health factor flag; no free text is stored |
| primary_homemaker | boolean | NOT NULL default false | Homemaker factor narrative |
| initial_contribution | bigint | NOT NULL default 0 | Assets brought in at cohabitation, cents |
| inheritances_or_gifts | bigint | NOT NULL default 0 | Received during the relationship, cents |
| participating | boolean | NOT NULL default false | Has this party signed in (joint mode) |
dc_children (index dc_children_matter_idx)
id uuid PK; matter_id uuid FK CASCADE; first_name text NOT NULL; date_of_birth date NOT NULL. Age at assessment determines dependency (under 18) and the under-five uplift.
dc_pool_items (index dc_pool_items_matter_idx)
| Column | Type | Constraints | Why it exists |
|---|---|---|---|
| id, matter_id | uuid | PK; FK CASCADE | Identity and ownership |
| kind | pool_item_kind | NOT NULL | asset, liability or superannuation; derived from category in the domain |
| category | text | NOT NULL | One of 16 categories |
| description | text | NOT NULL | Human label |
| ownership | ownership | NOT NULL | joint, A or B; drives allocation rules |
| agreed_value | bigint | nullable | Derived. Equal to both valuations when they match, otherwise null. Written by the repository from deriveAgreedValue, never by a user |
| retained_by | party_role | nullable | Who wants to keep the item after settlement |
| discovery_ref | text | nullable | Provenance: finding id from ad_discovery_requests.findings |
| created_at | timestamptz | NOT NULL default now() | Ordering |
dc_pool_item_valuations: the per-party valuation table
| Column | Type | Constraints | Why it exists |
|---|---|---|---|
| item_id | uuid | PK (with party_role), FK dc_pool_items CASCADE | One row per party per item, at most two rows |
| party_role | party_role | PK (with item_id) | Whose claim this is |
| value | bigint | NOT NULL | Claimed value in cents; non-negative by domain rule |
| source | valuation_source | NOT NULL default 'manual' | manual, estimate_for_partner, discovery, document |
| note | text | nullable | For example 'Accepted other party value' |
| recorded_at | timestamptz | NOT NULL default now() | When the claim was made |
Agreed value rule, in SQL terms. agreed_value = CASE WHEN count(*) = 2 AND min(value) = max(value) THEN min(value) END over the item's valuation rows. The repository computes this in TypeScript on save; a database trigger or generated view is a reasonable hardening step and is listed in the roadmap.
Schema: parenting, discovery, phase two, security
pa_care_arrangements
| Column | Type | Constraints | Why it exists |
|---|---|---|---|
| child_id | uuid | PK, FK dc_children CASCADE | One arrangement per child |
| matter_id | uuid | NOT NULL, FK dc_matters CASCADE | Denormalised for membership checks and RLS |
| nights_with_a_per_fortnight | smallint | NOT NULL default 7 | 0 to 14; B has the remainder |
| pattern | text | nullable | Free text such as 'Week about, changeover Sunday 5pm' |
ad_discovery_requests (index ad_discovery_requests_matter_idx)
| Column | Type | Constraints | Why it exists |
|---|---|---|---|
| id, matter_id | uuid | PK; FK CASCADE | Identity |
| provider | discovery_provider | NOT NULL | asic, ppsr, land_titles, ato_super, identity |
| subject | jsonb | NOT NULL | SubjectPerson: role, full name, optional DOB, other names, state |
| requested_by_role | party_role | NOT NULL | Who asked; accepted findings are valued for this party |
| status | discovery_status | NOT NULL | queued, running, complete, failed, blocked_tier, blocked_identity |
| findings | jsonb | NOT NULL default '[]' | Array of Finding including raw payload for audit |
| requested_at, completed_at | timestamptz | NOT NULL; nullable | Timing |
| error | text | nullable | Failure detail |
ph2_documents Phase two (table exists, only status 'uploaded' is reachable today)
| Column | Type | Constraints | Why it exists |
|---|---|---|---|
| id, matter_id | uuid | PK; FK CASCADE | Identity |
| uploaded_by_role | party_role | NOT NULL | Documents belong to a side |
| kind | document_kind | NOT NULL | bank_statement, payslip, super_statement, tax_return, other |
| storage_path | text | NOT NULL | Object path in the private Supabase Storage bucket |
| status | document_status | NOT NULL default 'uploaded' | uploaded, queued, parsed, failed, rejected |
| extraction | jsonb | nullable | Structured output per Section 14.1 schemas |
| parser_version | text | nullable | Reproducibility; re-parse when the parser changes |
| uploaded_at, parsed_at | timestamptz | NOT NULL; nullable | Timing |
ph2_disclosure_flags Phase two
| Column | Type | Constraints | Why it exists |
|---|---|---|---|
| id, matter_id | uuid | PK; FK CASCADE | Identity |
| kind | disclosure_flag_kind | NOT NULL | gap, inconsistency, undisclosed_income, undisclosed_asset |
| severity | smallint | NOT NULL default 2 | 1 low, 2 medium, 3 high |
| message | text | NOT NULL | Plain-English statement of what was found |
| related_item_id | uuid | nullable, FK dc_pool_items SET NULL | Links a flag to the item it questions |
| source_document_id | uuid | nullable, FK ph2_documents SET NULL | Evidence |
| status | disclosure_flag_status | NOT NULL default 'open' | open, dismissed, resolved |
| created_at | timestamptz | NOT NULL default now() | Ordering |
Indices
Unique: id_users_email_idx, id_invitations_token_idx, id_matter_members_role_idx (matter_id, role), dc_parties_matter_role_idx (matter_id, role). Non-unique: dc_children_matter_idx, dc_pool_items_matter_idx, ad_discovery_requests_matter_idx. Composite primary keys on dc_pool_item_valuations (item_id, party_role) and id_matter_members (matter_id, user_id) double as lookup indices. Recommended additions: id_sessions(user_id), id_invitations(matter_id, status), ph2_documents(matter_id, status), ph2_disclosure_flags(matter_id, status).
Row level security on Supabase
Today all database access goes through Next route handlers using a server connection, and authorisation is a membership check: the handler loads id_matter_members for the session user and the requested matter and returns 403 otherwise. RLS is therefore defence in depth rather than the primary control, and the policies are not yet in migration 0000. Recommended policy shape, applied to every table carrying matter_id:
alter table dc_pool_items enable row level security;
create policy matter_member_rw on dc_pool_items
for all using (
exists (
select 1 from id_matter_members m
join id_users u on u.id = m.user_id
where m.matter_id = dc_pool_items.matter_id
and u.auth_subject = auth.uid()::text
)
);
-- dc_pool_item_valuations joins through dc_pool_items.
-- id_users: a user may read and update only their own row.
-- ph2_documents: members may read; only the uploading role may delete.
The server role used by route handlers bypasses RLS, so the membership check in code remains mandatory. If a browser-side Supabase client is introduced later, these policies become the primary control.
Settlement methodology
Plain statement. The engine in src/contexts/settlement-modelling/domain/engine.ts is a heuristic model. It is versioned (ENGINE_VERSION = '1.0.0'), deterministic (same inputs, same output, no randomness, no model calls), and explainable (every adjustment is a Factor with a plain-English reason and a legislative basis). It is not legal advice and it does not predict what a particular judge will do.
It follows the four-step approach the Federal Circuit and Family Court of Australia applies under s79 (married) and s90SM (de facto) of the Family Law Act 1975, as restated by the 2024 amendments.
flowchart LR
S1["Step 1: Identify and value the pool"] --> S2["Step 2: Assess contributions"] --> S3["Step 3: Consider current and future circumstances"] --> S4["Step 4: Just and equitable check"]
S1 --- N1["summariseDisclosure: low pool, high pool, referencePool = midpoint, disputed items, completeness"]
S2 --- N2["Factors: INITIAL_CONTRIBUTION, INHERITANCE_GIFT, HOMEMAKER_PARENT. Percent band clamped 25 to 75"]
S3 --- N3["Factors: INCOME_DISPARITY, CARE_OF_CHILDREN, HEALTH_CAPACITY, AGE_GAP. Points capped at 20"]
S4 --- N4["Add points to contribution band, clamp 10 to 90, convert to dollars across low and high pool, attach notes, cautions, disclaimer"]
style S1 fill:#EAF0F6,stroke:#103654
style S2 fill:#EAF0F6,stroke:#103654
style S3 fill:#EAF0F6,stroke:#103654
style S4 fill:#FBE9DA,stroke:#E97819
assessSettlement as a pipeline. Each step's output is reported as a StepOutcome with a one-line summary.Step 1, in plain English
Add up everything both people own and owe, including superannuation, which is shown separately because courts commonly treat it as its own pool. Where the two people disagree about a value, keep both: a 'low' pool using the values least favourable to a big total, and a 'high' pool using the most favourable. The model runs on the midpoint but reports dollars across the whole span.
Step 2, in plain English
Start at 50/50. Move toward the person who brought more in at the start, but less so the longer the relationship lasted, because early contributions get absorbed into the joint effort. Move toward the person who received an inheritance or gift during the relationship, at a discount because the money was usually spent on the household. Homemaker and parenting contributions are treated as equal in worth to earning income, so they do not move the number on their own; they offset the higher earner's financial contribution.
Step 3, in plain English
Look forward. Who earns less, or nothing? Who has the children most of the time, and are any under five? Does either person have a health problem that limits their capacity? Is one person much older with fewer working years left? Each of these moves the number toward the person with greater need, within a cap so future needs never swamp contributions.
Step 4, in plain English
Add the future needs adjustment to the contributions band, make sure the result stays inside 10% to 90%, convert to dollars across the low and high pool, and attach the notes about what widened the range and the cautions about what is missing. Then attach the disclaimer. The output is always a range.
Exact rules and constants
Constants live in TUNING and are the only numbers a legal reviewer needs to argue with. Points are percentage points of the net pool moving toward party A; negative means toward B. band(low, mid, high) rounds to one decimal.
| Constant | Value | Used by |
|---|---|---|
initialContributionErosionYears | 20 | Weight of initial contributions falls linearly to the floor over 20 years |
initialContributionMinWeight | 0.15 | Floor weight; an initial contribution never fully disappears |
initialContributionCapPoints | 20 | Maximum shift from initial contributions |
inheritanceWeight | 0.6 | Fixed weighting for inheritances and gifts |
inheritanceCapPoints | 10 | Maximum shift from inheritances |
contributionsFloor, contributionsCeiling | 25, 75 | Clamp on the step 2 percentage for A |
contributionsBandNarrow, contributionsBandWide | 2.5, 5 | Minimum half-width of the contributions band; wide when the net shift exceeds 5 points |
futureNeedsCapPoints | 20 | Clamp on the step 3 total (each of low, mid, high) |
finalFloor, finalCeiling | 10, 90 | Clamp on the final percentage |
smallPoolThreshold | 5,000,000 cents ($50,000) | Below this a caution about need over contribution is added |
Step 2 factors (group contributions)
| Factor | Rule |
|---|---|
INITIAL_CONTRIBUTION | Skipped if both parties' initial contributions are zero. weight = clamp(1 - years/20, 0.15, 1). diffShare = (A - B) / referencePool x 100 (0 if the pool is not positive). mid = clamp(diffShare x weight, -20, 20). spread = min(|mid| x 0.4, 5). Band is mid - spread, mid, mid + spread. |
INHERITANCE_GIFT | Skipped if both are zero. diffShare as above. mid = clamp(diffShare x 0.6, -10, 10). spread = min(|mid| x 0.4, 4). |
HOMEMAKER_PARENT | Always present. Band is 0, 0, 0. Explains that homemaker and parenting contributions are treated as equal in worth to income and offset the higher earner's financial contribution. |
| Combination | Sum the bands. bandWidth = |sum.mid| > 5 ? 5 : 2.5. contribMid = clamp(50 + sum.mid, 25, 75). Low is clamp(min(50 + sum.low, contribMid - bandWidth), 25, 75); high is clamp(max(50 + sum.high, contribMid + bandWidth), 25, 75). |
Step 3 factors (group future_needs)
| Factor | Rule |
|---|---|
INCOME_DISPARITY | Skipped if both incomes are zero. Identify the lower earner. If the lower earner has zero income and is unemployed or carer: band 7.5, 10, 12.5 toward them. Otherwise ratio = higher / lower (infinity if lower is zero): ratio below 1.25 gives no factor; 1.25 to under 1.75 gives 2, 2.5, 4; 1.75 to under 3 gives 4, 5, 7.5; 3 or more gives 5, 7.5, 10. |
CARE_OF_CHILDREN | Skipped if there are no dependent children (under 18) or no primary carer. base = min(2.5 x dependents, 7.5) + (any child under five ? 2.5 : 0). Band is base - 1, base, base + 2.5 toward the primary carer. |
HEALTH_CAPACITY | Only when exactly one party has healthOrCapacityConcern. Band 1, 2.5, 4 toward that party. |
AGE_GAP | Only when both dates of birth are present and the age difference is 10 years or more. Band 0, 1, 2.5 toward the older party. |
| Combination | Sum the bands, then clamp each of low, mid and high to -20 to 20. |
Step 4 arithmetic
lowA = clamp(contrib.low + min(fn.low, fn.high), 10, 90)
highA = clamp(contrib.high + max(fn.low, fn.high), 10, 90)
percentA = { low: min(lowA, highA), high: max(lowA, highA) } // rounded to 0.1
percentB = { low: 100 - percentA.high, high: 100 - percentA.low }
amountA = { low: max(pool.low.netTotal, 0) x percentA.low / 100,
high: max(pool.high.netTotal, 0) x percentA.high / 100 }
amountB = same shape with percentB
Range notes are added when items are disputed, when items have only one figure, and when the relationship is shorter than five years. Cautions are added for a small pool, for unvalued items, and when debts exceed assets on any view. The OUTPUT_DISCLAIMER constant is attached to every result.
Worked example: family-primary-carer
Produced by running computeResults(snapshot, 'A', new Date('2026-09-16')) against the real scenario with npx tsx. Nothing below is estimated. Sarah Whitfield is A, David Whitfield is B. Married, cohabited 1 June 2009, separated 15 March 2026: 16.8 years.
Step 1: the pool
| Item | Kind | Owner | Sarah | David | State | Keeps |
|---|---|---|---|---|---|---|
| Family home, Ashgrove QLD | asset | joint | 1,450,000 | 1,380,000 | disputed (gap 70,000) | A |
| Westpac home loan | liability | joint | 520,000 | 520,000 | agreed | |
| Joint savings | asset | joint | 62,000 | 62,000 | agreed | |
| David everyday account | asset | B | 9,000 | 14,500 | disputed (gap 5,500) | |
| 2021 Kia Carnival | asset | joint | 38,000 | 38,000 | agreed | A |
| 2023 Toyota Hilux | asset | B | 58,000 | 58,000 | agreed | B |
| Employee share plan (BHP) | asset | B | 95,000 | 71,000 | disputed (gap 24,000) | |
| Contents | asset | joint | 30,000 | 30,000 | agreed | |
| Car loan (Hilux) | liability | B | 31,000 | 31,000 | agreed | |
| Australian Retirement Trust (Sarah) | super | A | 61,000 | 61,000 | agreed | |
| UniSuper (David) | super | B | 486,000 | 486,000 | agreed |
| Pool view | Assets | Liabilities | Super | Net non-super | Net total |
|---|---|---|---|---|---|
| Low (smaller asset claims) | 1,648,000 | 551,000 | 547,000 | 1,097,000 | 1,644,000 |
| High (larger asset claims) | 1,747,500 | 551,000 | 547,000 | 1,196,500 | 1,743,500 |
| Sarah's perspective | 1,742,000 | 551,000 | 547,000 | 1,191,000 | 1,738,000 |
| David's perspective | 1,653,500 | 551,000 | 547,000 | 1,102,500 | 1,649,500 |
Counts: 8 agreed, 3 disputed, 0 awaiting, 0 unvalued; completeness 1.0. referencePool = (1,644,000 + 1,743,500) / 2 = $1,693,750.
Step 2: contributions
Both initial contributions are zero and both inheritance figures are zero, so INITIAL_CONTRIBUTION and INHERITANCE_GIFT are skipped. HOMEMAKER_PARENT records that Sarah is the primary homemaker with a 0, 0, 0 band. Sum mid is 0, so the narrow band width 2.5 applies. Contributions for Sarah: 47.5% / 50% / 52.5%.
Step 3: future needs
| Factor | Why it fires | Low | Mid | High |
|---|---|---|---|---|
INCOME_DISPARITY | Sarah has zero income and status carer; David earns $215,000 | +7.5 | +10 | +12.5 |
CARE_OF_CHILDREN | Sarah is primary carer of 3 dependents (Ella 14, Max 11, Ruby 4); base = min(7.5, 7.5) + 2.5 for Ruby = 10 | +9 | +10 | +12.5 |
HEALTH_CAPACITY | Neither party flagged | not applied | ||
AGE_GAP | Sarah 42, David 45; gap under 10 years | not applied | ||
| Sum, then cap at 20 | 16.5 / 20 / 25 becomes | +16.5 | +20 | +20 |
Step 4: result
lowA = 47.5 + 16.5 = 64.0; highA = 52.5 + 20 = 72.5; both inside 10 to 90. David: 27.5% to 36%. Dollars: Sarah $1,052,160 (64% of the low pool) to $1,264,037.50 (72.5% of the high pool); David $452,100 to $627,660. Range note: '3 items have different values from each party, so the dollar range spans both views of the pool.' No cautions.
Split scenarios (perspective A, pool $1,738,000)
| Scenario | Sarah % | Sarah target | Sarah retains | David retains | Balancing payment |
|---|---|---|---|---|---|
| Equal split | 50 | 869,000 | 1,075,000 (assets 1,534,000, debts 520,000, super 61,000) | 663,000 (assets 208,000, debts 31,000, super 486,000) | Sarah pays David 206,000 |
| Lower end | 64 | 1,112,320 | David pays Sarah 37,320 | ||
| Midpoint | 68.3 | 1,187,054 | David pays Sarah 112,054 | ||
| Upper end | 72.5 | 1,260,050 | David pays Sarah 104,000 plus a super split of 81,050 |
The Westpac loan follows the home Sarah keeps ('Follows the property Sarah Whitfield is keeping (refinance into their name)'), so her retained position is net of the mortgage. At an equal split the note reads 'Sarah Whitfield owes a balancing payment larger than their readily available cash. This usually means refinancing a property or selling an asset.' At the lower end David pays from cash or investments; at the midpoint he too would need to refinance or sell; at the upper end his non-super assets run out, so $104,000 is paid from cash-like assets and 'a superannuation splitting order of about $81,050 would move part of the balance'.
Split modelling rules
modelSplit in split-modeller.ts turns a percentage into a practical picture. It uses effectiveValue from the chosen perspective, so the two parties can see slightly different dollar figures until they agree.
Allocation rules, in order
- Superannuation goes to its member (shared if jointly owned). Reason: 'Superannuation stays with the member unless a splitting order is made'.
- An item flagged
retainedBygoes to that party. Reason: 'X has asked to keep this'. - A solely owned item stays with its owner. Reason: 'Solely held, stays with the holder'.
- A joint mortgage with no retain flag follows the property when every retained
real_propertyitem is kept by the same party: it is allocated to that party with the reason 'Follows the property X is keeping (refinance into their name)'. If retained properties are split between the parties, or none is retained, the next rule applies. - Jointly owned real estate, or a joint mortgage, with no retain flag is otherwise treated as sold and shared 50/50. Reason: 'Jointly owned, modelled as sold with proceeds shared' or 'Discharged from sale proceeds'. Half the sale value counts as cash-like for the payer.
- Any other joint item is shared equally.
Balancing the books
target = splitByPercent(max(poolTotal, 0), percentA);diffA = target.a - A.netRetained. Differences under one dollar are ignored.- If the payer's cash-like assets (bank accounts and shares allocated to them, plus half of shared real estate) cover the amount: a balancing payment 'from cash, investments or sale proceeds'.
- Else if the payer's non-super net position covers it: a balancing payment, with the note that it usually means refinancing or selling.
- Else: pay what cash-like assets allow, take the rest from the payer's superannuation as a
superSplitSuggestion, and if a remainder is still unreachable, say so and suggest revisiting who keeps what. - Positions after adjustment and
percentOfPoolare reported for both parties. If the family home was modelled as sold, a note invites the user to mark it as kept to see the refinancing picture.
Standard scenarios
standardScenarios returns equal split (50), the low end, the midpoint and the high end of the indicative range, de-duplicated when two coincide. The results page shows these four first, before the range.
Why the mortgage follows the home. In the worked example Sarah keeps the home, so the Westpac loan is allocated to her and her retained position is net of it. This is what a refinance into one name looks like in practice, and it is why the balancing payment at an equal split is $206,000 rather than the far larger figure a sale-and-share model would show. Split modelling changes balancing payments only; the indicative range is untouched.
Asset discovery
Deep asset discovery is the paid differentiator. Each registry is a DiscoveryProvider behind a port with one method, search(subject), so the mocks shipped today can be swapped for real adapters without touching the domain or the UI. Findings are leads; a person accepts them into disclosure.
| Code | Label | What it returns | Tier | Verified identity |
|---|---|---|---|---|
identity | Identity verification | Verifies who you are before searching registries in your own name | free | no |
asic | ASIC companies and directorships | Officeholder and shareholder records; suggests business_interest, trust_interest (corporate trustee) or shares | discovery | no |
ppsr | PPSR security interests | Registered security interests over vehicles and personal property; suggests personal_loan | discovery | no |
land_titles | State land titles | Registered proprietor searches across state and territory registries; suggests real_property | discovery | yes |
ato_super | ATO superannuation lookup | Super accounts in your name including lost and unclaimed super; suggests accumulation or smsf | discovery | yes |
Gating
gateDiscovery(provider, tier, identityVerified) is a pure function: if the provider requires the discovery tier and the user is not on it, the request is blocked_tier; else if it requires verified identity and the user is not verified, blocked_identity; else queued. runDiscovery records the blocked request as-is (so the UI can explain), otherwise calls the adapter and records complete with findings or failed with the error. It never throws for expected outcomes.
sequenceDiagram
autonumber
actor U as User (discovery page)
participant API as POST /api/matters/:id/discovery
participant G as gateDiscovery
participant R as runDiscovery
participant P as Provider adapter (mock or real)
participant DB as ad_discovery_requests
participant DC as Disclosure
U->>API: { provider: land_titles, subjectRole: B }
API->>API: load session user (tier, identityVerified), check membership
API->>G: gate(land_titles, tier, identityVerified)
alt tier is free
G-->>API: blocked_tier
API->>DB: insert request status blocked_tier
API-->>U: DiscoveryRequest (UI shows Upgrade to discovery)
else identity not verified
G-->>API: blocked_identity
API->>DB: insert request status blocked_identity
API-->>U: DiscoveryRequest (UI shows Verify identity)
else queued
API->>R: run(cmd, providers)
R->>P: search(subject)
P-->>R: Finding[] with confidence and suggestedItem
R-->>API: request complete
API->>DB: insert request with findings jsonb
API-->>U: DiscoveryRequest with findings
U->>API: POST .../findings/:findingId/accept
API->>DC: createPoolItem with discoveryRef, recordValuation for requesting role, source discovery
API-->>U: { item }
end
POST /api/account/upgrade) and identity verification (POST /api/account/verify-identity) are separate calls, both mocked today.Mock versus real integration, tiering and pricing
The mock adapters
MockProvider is deterministic: results depend only on the subject's full name (case-insensitive), so demos and tests are repeatable. Two personas have canned findings, Marcus Delfino (ASIC x3, PPSR x1, land titles x1, ATO super x2, including an SMSF and a lost AMP account) and Priya Raman (land titles x1, an explicit 'no records' from ASIC, ATO super x1). Any other name returns one 'No records found' finding that suggests trying previous names. mockVerifyIdentity verifies anyone with a date of birth. Optional latency simulates a real call.
Real integration notes
| Provider | Real source | Constraints to design around |
|---|---|---|
| ASIC | ASIC Connect and ASIC Registry Search (paid extracts), or a licensed information broker | Per-search fees; name-based search returns candidates that need disambiguation by date of birth or address; a person search reveals directorships but shareholdings need company extracts. |
| PPSR | PPSR B2G interface, search by grantor (individual requires name and date of birth) | Account registration, per-search fee, and the grantor search on an individual is restricted to permitted purposes; legal review of purpose is required. |
| Land titles | Per-state registries (QLD Titles Registry, NSW LRS, Landata VIC, Landgate WA, and others) via an aggregator broker | Name searches are not offered in every state and are fee-bearing; results are title references that then need a title search for encumbrances. The verified-identity gate reflects registry terms of use. |
| ATO super | ATO Online via myGov | There is no third-party API for another person's super. The flow is user-consent driven for the user's own accounts. Discovery of a partner's super is by disclosure request, subpoena or court order, which the product should explain honestly. |
| Identity | A DVS-backed KYC vendor (document plus liveness) | Store only the verification reference and result, never the document images. |
Tiering
| Tier | Includes | Price |
|---|---|---|
free | Anonymous modelling, saving, joint mode, results, identity verification, unlimited matters | Free |
discovery | Everything in free plus all registry providers for both parties of a matter, and phase two document parsing when it ships | Placeholder: one-off per-matter fee in the low hundreds of dollars, or pass-through registry fees plus a platform fee. To be validated against real registry costs before launch. |
Tier is a property of the user (id_users.tier), so a discovery-tier user can search on any matter they are a member of. POST /api/account/upgrade is a mock payment today; the roadmap names Stripe Checkout for the real flow.
Identity and authentication
The free tier needs no account. A user record is created only when someone chooses to save or invite. Authentication is delegated to the AuthProvider port; Settle then issues its own session.
Passwordless flows
| Step | Six-digit email code | Magic link | |
|---|---|---|---|
| Start | POST /api/auth/start { email, method: 'email_otp' } returns { challengeId } | POST /api/auth/start { email, method: 'magic_link' } | GET /api/auth/google?redirectTo= returns 302 to the provider URL |
| Provider (Supabase) | auth.signInWithOtp with shouldCreateUser: true; challengeId is the lowercased email because Supabase verifies by (email, token) | Same call with emailRedirectTo = NEXT_PUBLIC_APP_URL | {SUPABASE_URL}/auth/v1/authorize?provider=google |
| Provider (local) | Challenge kept in memory, code is always 123456, printed to the server log, expires after 10 minutes | Same | /api/auth/local-google signs in as google.demo@example.com |
| Verify | POST /api/auth/verify { challengeId, code }; isValidOtp checks six digits client-side first | Link lands on the app, which exchanges the token and continues as verify | Callback exchanges code |
| Result | Upsert id_users by email (set auth_subject on Supabase), insert id_sessions, set httpOnly settle_session cookie, return { user }. Facebook is deliberately not offered. | ||
Anonymous to saved migration
- The user presses Save or Invite. The app remembers the intent and sends them to
/sign-in. - After sign-in the app reads
localStorage['settle.matter.v1']and callsPOST /api/matters { snapshot }. The server accepts the browser-generated ids, saves the aggregate, and creates a membership withsnapshot.matter.ownerRole. - The browser keeps working with the same snapshot shape and switches to
PUT /api/matters/:idon change. The local copy is kept as a cache until the first successful save, then cleared to avoid two sources of truth. - If the user already has matters, the dashboard offers to save the anonymous one as a new matter rather than merging.
Invitations and membership
createInvitationvalidates the email, lowercases it, offers the opposite role, generates a UUID token, and sets a 14-day expiry (ttlDaysdefault).acceptInvitationfails withINVITATION_NOT_PENDING,INVITATION_EXPIREDorINVITATION_EMAIL_MISMATCH('Sign in with the email address the invitation was sent to'). Success adds a membership row; the unique (matter_id, role) index prevents two users holding the same role.- Inviting moves the matter to joint mode permanently (
inviteToJoint). - Sessions are server-side rows with an expiry. Sign-out deletes the row and clears the cookie.
API contract
Route handlers live under src/app/api. All bodies and responses are JSON. Auth is the settle_session httpOnly cookie. Errors are { error: { code, message } } with status 400 (validation or domain error), 401 (no session), 403 (not a member), 404 (not found). user is { id, email, displayName, tier, identityVerified }.
| Method | Path | Body | Response |
|---|---|---|---|
| POST | /api/auth/start | { email, method: 'magic_link' | 'email_otp' } | { challengeId } |
| POST | /api/auth/verify | { challengeId, code } | { user } and sets cookie |
| GET | /api/auth/me | { user | null } | |
| POST | /api/auth/signout | { ok: true } | |
| GET | /api/auth/google?redirectTo= | 302 to Google (local: signs in as google.demo@example.com) | |
| GET | /api/matters | { matters: [{ matterId, role, status, mode, partyNames, updatedAt }] } | |
| POST | /api/matters | { snapshot } | { matterId, role } (creates membership as snapshot.matter.ownerRole) |
| GET | /api/matters/:id | { snapshot, role } | |
| PUT | /api/matters/:id | { snapshot } | { ok: true } |
| GET | /api/matters/:id/results?perspective=A | MatterResults | |
| POST | /api/matters/:id/invite | { email } | { invitation, acceptUrl } (acceptUrl for local demos) |
| POST | /api/invitations/:token/accept | { matterId, role } | |
| GET | /api/matters/:id/discovery | { requests: DiscoveryRequest[] } | |
| POST | /api/matters/:id/discovery | { provider, subjectRole } | DiscoveryRequest |
| POST | /api/matters/:id/discovery/:requestId/findings/:findingId/accept | { item } (adds PoolItem with discoveryRef) | |
| POST | /api/account/upgrade | { user } tier becomes discovery (mock payment) | |
| POST | /api/account/verify-identity | { user } identityVerified becomes true (mock) | |
| GET | /api/demo/scenarios | { scenarios: [{ slug, title, tagline, tier, accounts }] } | |
| POST | /api/demo/:slug/login | { role: 'A' | 'B' } | { matterId, user } and sets cookie |
Conventions
- Request bodies are validated with zod at the edge; domain errors (
DomainError.code) map to 400 with the domain message, which is written for end users. - The snapshot is validated as a whole. The server recomputes
agreedValueon save and ignores any client-supplied value. - In joint mode a PUT from role A may only change A's valuations, A's party row, shared structure (items, children, care) and retain flags; B's valuations are preserved from the stored copy. This prevents one side rewriting the other's claims.
GET /api/demo/scenariosandPOST /api/demo/:slug/loginexist for demos only. They sign a visitor in as a seeded demo account without a code, so they must be disabled in production behind an environment flag (see Appendix B); the handler returns 404 when the flag is off.- Phase two endpoints are listed in Section 14 and are not implemented.
UI theme and accessibility
Tokens are exported from src/lib/brand.ts as THEME and mirrored as Tailwind theme values. Red is reserved for warnings and destructive actions; disagreement is never red, because disagreement is normal.
| Token | Hex | Use |
|---|---|---|
navy | #103654 | Headings, primary buttons, party A in charts, navigation |
terracotta | #E97819 | The one primary action per screen, links, active states, party B in charts, disputed chips, callout borders |
surface | #F5F2EA | Page background |
slate | #5B6B7A | Secondary text, awaiting chips |
ink | #1B2733 | Body text |
white | #FFFFFF | Cards |
success | #2E7D5B | Agreed chips, saved confirmations |
danger | #B42318 | Disclaimer callout border, destructive confirmations (remove item, revoke invitation, delete matter) only |
Typography and tone
Plus Jakarta Sans (or the system stack when offline), 16px base, 1.6 line height, generous whitespace. Copy is calm and concrete: 'David pays Sarah $112,054' rather than 'a transfer may be required'. Second person, no jargon without a plain-English gloss, and every legislative term shown with its basis string from the factor.
Accessibility commitments (WCAG 2.2 AA)
- Colour is never the only carrier of meaning: chips carry text, party bars carry labels, the range shows numbers.
- Contrast: navy and ink on white exceed 7:1; terracotta on white is used for text at 18px bold or larger, or paired with an underline for links; body-size terracotta text is avoided.
- All forms have visible labels, described errors (
aria-describedby), and the OTP input accepts paste of six digits. - Keyboard: the stepper, chips with actions and scenario cards are reachable in DOM order; focus rings are visible on the navy palette.
- Money inputs accept whole dollars and format on blur; screen readers hear 'one million four hundred fifty thousand dollars', not digit strings.
- Reduced motion respected for the scenario rail; no auto-advancing content.
- The disclaimer is a landmark region (
role="note",aria-label="Important notice") so it is discoverable by rotor.
Mobile and React Native readiness
The client wants a web app that works well on a phone now and a path to a native app later. Both are designed in, and docs/MOBILE.md is the working reference.
The web app is mobile-first
| Concern | Behaviour |
|---|---|
| Targets | 390px is the primary design width; 360px is checked. Desktop is the enhancement, not the baseline. |
| Navigation | Header collapses to a hamburger under 768px. The matter stepper is a horizontally scrollable chip row above each screen. |
| Results | Split scenarios collapse to a one-line summary with an expander; a sticky indicative-range bar keeps the range one tap away while scrolling; the disclaimer stays on the range card. |
| Disclosure | A sticky bottom bar carries the add-item action so it is reachable from any row. |
| Touch and input | 44px minimum touch targets; money inputs use inputMode="numeric"; dates use native pickers; the OTP field accepts a pasted six-digit code. |
| Device chrome | viewport-fit=cover with safe-area inset padding so sticky bars clear the home indicator. |
| Installable | public/manifest.webmanifest: name Settle, display: standalone, portrait, theme_color #103654, background_color #F5F2EA, SVG plus 192 and 512 PNG icons, alongside Apple web-app meta tags. Adding to the home screen is the cheapest 'app' to ship first. |
The React Native path
- Platform-agnostic core.
src/core/index.tsis a barrel of everything that runs unchanged in the browser, Node and Hermes: the five contexts' domain code (plusrunDiscovery), theMatterSnapshotread model andcomputeResults, the demo scenarios,createApi({ baseUrl, fetchImpl }), format helpers,BRANDandTHEME, and theStorageAdaptertype.tests/unit/core/platform-agnostic.test.tswalks its transitive imports and fails if any file toucheswindow,document,localStorage,nextorreact. This is the future@settle/coreworkspace package. - Persistence through a port.
src/lib/client/storage.tsdefinesStorageAdapter { getItem, setItem, removeItem }where each method may return a value or a Promise.webLocalStorageAdapterwrapslocalStoragewith try/catch so private mode and quota errors degrade to no-ops;memoryStorageAdapterserves tests and server rendering; an Expo app supplies an AsyncStorage adapter. Anonymous matters use the same keysettle.matter.v1on every platform, so a snapshot moves between web and native with no conversion. - Same API, one change. The native app points
createApiat the deployed Next.js API. The API today trusts thesettle_sessionhttpOnly cookie; native clients need it to also acceptAuthorization: Bearer <Supabase access token>and resolve the user the same way. Route shapes do not change; the mobile app injects the header throughfetchImpl. - Screens mirror the page list. Expo Router screens use the same route names as Section 4: start with a segmented control and native date pickers, children with a 0 to 14 nights slider, disclosure as an assets/debts/super tab bar with card rows and a floating add button, results with collapsed scenario cards and a sticky range bar, share using the native share sheet. Only the UI primitive layer is rewritten; providers and hooks are plain React and port as they are.
- Auth on device. Supabase Auth directly with
expo-secure-storeas the session store: email OTP throughsignInWithOtpandverifyOtpwith one-time-code autofill, Google throughexpo-auth-sessionand asettle://auth/callbackscheme, Facebook still unsupported. - Deep links. Register
settle://and the universal link domain and map/invite/[token]to the invite screen; invitation emails keep the web URL, which opens the app when installed. - Stays web only. API routes, Postgres and Drizzle, server-side auth providers, the SEO landing page, redirect-based Google sign-in, clipboard fallbacks and the PWA manifest.
flowchart TB
subgraph core["@settle/core (today src/core/index.ts)"]
direction LR
DOM["Five contexts' domain + runDiscovery"]
RM["MatterSnapshot, computeResults"]
DEMO["Demo scenarios"]
API["createApi({ baseUrl, fetchImpl })"]
FMT["format helpers, BRAND, THEME, StorageAdapter type"]
end
subgraph web["apps/web: Next.js"]
direction LR
WUI["React pages + Tailwind"]
WST["webLocalStorageAdapter"]
end
subgraph rn["apps/mobile: Expo React Native"]
direction LR
RUI["Expo Router screens + RN primitives"]
RST["asyncStorageAdapter"]
SEC["expo-secure-store session"]
end
SRV["Next.js API on Vercel: cookie session today, plus bearer token for native"]
DB[("Postgres")]
WUI --> core
RUI --> core
WUI -->|"settle_session cookie"| SRV
RUI -->|"Authorization: Bearer (Supabase token)"| SRV
SRV --> DB
style core fill:#FBE9DA,stroke:#E97819
style rn stroke-dasharray: 5 5
Sequence: extract src/core to packages/core and switch web imports (the guard test moves with it); add bearer-token support to the API auth helper; scaffold apps/mobile with MatterProvider on the AsyncStorage adapter; build screens in the order users meet them, results getting the most design time; then auth, invite deep link, dashboard, discovery and demo.
Testing strategy and coverage
Vitest runs unit and integration tests; Playwright runs a thin end-to-end layer. vitest.config.ts sets the coverage gates for CI: lines 80, functions 80, branches 75, statements 80, measured with v8 over src/contexts, src/read-models, src/demo, src/db, src/lib and src/components, excluding type declarations and the Supabase auth provider (exercised only against a live project).
Current state
557 tests across 38 files pass via npm test. npm run test:coverage reports lines 97%, statements 97%, functions 95%, branches 96%, comfortably above the gates. Three Playwright journeys run on two projects via npm run test:e2e.
Commands
npm test unit and integration; npm run test:coverage with thresholds; npm run test:e2e Playwright (starts next start on port 3000 unless E2E_NO_SERVER is set); npm run demo:video records the walkthrough.
| Layer | Lives in | What it covers |
|---|---|---|
| Unit | tests/unit/**/*.test.ts(x): shared, disclosure, parenting, settlement-modelling, asset-discovery, identity, read-models, demo, server, client, components (jsdom via environmentMatchGlobs) and core. Fixtures in tests/unit/helpers/fixtures.ts. | Money rounding and exact splits; agreed value and agreement states; low and high pools; care bands and the cost percent table; every engine factor boundary, cap and clamp; determinism and six scenario snapshots; allocation order including the mortgage-follows-property rule, balancing and super split fallback; discovery gating and mocks; invitation lifecycle and OTP shape; storage adapters and the API client; UI components. tests/unit/core/platform-agnostic.test.ts fails if the src/core barrel transitively imports window, document, localStorage, next or react. |
| Integration | tests/integration/**/*.test.ts against a real Postgres database settle_test created through createDb: db-client, matter-repository and server/. | MatterRepository save and load round trip preserving the snapshot and recomputing agreed_value; server services for matters, invitations, discovery, demo seeding and auth; a selection of route handlers end to end against the database. |
| End to end | tests/e2e/journeys.spec.ts, Playwright projects desktop (Desktop Chrome) and mobile (Pixel 7). | Three journeys: an anonymous user starts a matter, discloses an asset and sees a ranged result; a demo scenario shows splits first, then an indicative range with the disclaimer attached; a signed-in discovery run surfaces the hidden company and adds it to disclosure. |
Video walkthrough
scripts/record-walkthrough.ts (npm run demo:video) drives the app in Playwright, records six captioned chapters (intro, anonymous modelling, joint mode, complex pool and discovery, sign-in and share, mobile) with on-screen title cards and captions, and stitches them with ffmpeg into docs/video/walkthrough.mp4. It is regenerated on each tagged build and is the release artefact for client review.
Rules of the pyramid
- The engine is tested with golden snapshots of all six demo scenarios; a change to
TUNINGmust update the snapshots and bumpENGINE_VERSION. - Domain tests never touch the database. Repository tests never assert on engine output.
- E2E tests use the local auth provider and the demo login endpoint, never a mail inbox.
- The platform-agnostic guard runs with the unit suite so a stray browser import into the domain fails the build, not the native app.
Phase two: AI document parsing
Financial disclosure is slow because people transcribe documents by hand. Phase two lets a party upload bank statements, payslips, superannuation statements and tax returns; a worker extracts structured facts; a rule-based checker compares those facts with the disclosed pool and raises flags. Everything the AI produces is a proposal a person accepts or dismisses.
sequenceDiagram
autonumber
actor U as Party (browser)
participant API as Route handlers
participant STO as Supabase Storage (private bucket)
participant DB as Postgres
participant Q as Queue
participant W as Parsing worker
participant M as Extraction model
participant C as Disclosure checker (rules)
U->>API: POST /api/matters/:id/documents (kind, file)
API->>STO: put object under matters/:id/:docId
API->>DB: insert ph2_documents status uploaded
API-->>U: { document }
U->>API: POST /api/matters/:id/documents/:docId/parse
API->>DB: status queued
API->>Q: enqueue { documentId }
Q->>W: deliver job
W->>STO: fetch object (signed URL, short lived)
W->>W: classify kind, OCR if scanned, redact account numbers to last 4 digits
W->>M: extract with JSON schema for the kind
M-->>W: extraction JSON + confidence per field
W->>W: validate against schema, sanity checks (dates, totals reconcile)
W->>DB: update ph2_documents extraction, parser_version, status parsed (or failed)
W->>C: run checks for matter
C->>DB: read dc_parties, dc_pool_items, dc_pool_item_valuations, all parsed extractions
C->>DB: upsert ph2_disclosure_flags (open), skip duplicates
U->>API: GET /api/matters/:id/flags
API-->>U: flags with suggested actions
U->>API: POST /api/matters/:id/documents/:docId/suggestions/:n/accept
API->>DB: createPoolItem or recordValuation (source document), flag resolved
Components
| Component | Design |
|---|---|
| Storage | Private Supabase Storage bucket; object path matters/{matterId}/{documentId}; access only via short-lived signed URLs from the server; encryption at rest by Supabase. |
| Queue and worker | Options: a Vercel background function triggered by a queue, or a Supabase Edge Function on a database webhook. Decision open; the interface is { documentId }. |
| Extraction model | A hosted LLM with structured output constrained to the per-kind JSON schema. Prompt receives the redacted document text or page images and the schema, nothing about the pool or the other party. |
| Checker | Pure TypeScript in a new bounded context, documents, with rules R1 to R8 (next page). Runs after every parse and whenever the pool changes. |
| Versioning | parser_version on each document; re-parse on upgrade; flags reference the source document so evidence survives re-parsing. |
Extraction schemas per document type
Stored in ph2_documents.extraction. Every money value is integer cents. Every field carries the page it came from and a confidence 0 to 1 in a parallel meta block (omitted below for brevity). Account numbers are kept as last four digits only.
bank_statement
{
"kind": "bank_statement",
"institution": "Westpac",
"accountLast4": "4471",
"accountType": "transaction",
"holders": ["Sarah Whitfield", "David Whitfield"],
"period": { "from": "2026-01-01", "to": "2026-03-31" },
"openingBalance": 5810000,
"closingBalance": 6200000,
"transactions": [
{ "date": "2026-02-01", "description": "Salary BHP",
"amount": 1245000, "category": "income" },
{ "date": "2026-02-03", "description": "Transfer to 88-2210",
"amount": -200000, "category": "transfer_out",
"counterpartyLast4": "2210" },
{ "date": "2026-02-05", "description": "Westpac home loan",
"amount": -412000, "category": "loan_repayment" }
],
"recurring": [
{ "description": "Transfer to 88-2210", "cadence": "monthly",
"typicalAmount": -200000 }
]
}
payslip
{
"kind": "payslip",
"employee": "David Whitfield",
"employer": "BHP Group Ltd",
"employerAbn": "49004028077",
"payDate": "2026-03-12",
"period": { "from": "2026-02-27", "to": "2026-03-12" },
"gross": 826900,
"net": 561200,
"tax": 231400,
"superContribution": 95100,
"superFund": "UniSuper",
"ytd": { "gross": 15310000, "asAt": "2026-03-12",
"financialYearStart": "2025-07-01" },
"annualisedGross": 21600000,
"allowances": [], "deductions": [
{ "description": "Salary sacrifice super", "amount": 50000 }
]
}
super_statement
{
"kind": "super_statement",
"member": "David Whitfield",
"fund": "UniSuper", "fundAbn": "91385943850",
"memberNumberLast4": "3391",
"productType": "accumulation",
"asAt": "2026-06-30",
"balance": 48600000,
"preserved": 48600000,
"insurance": { "death": 50000000, "tpd": 50000000 },
"contributionsYtd": { "employer": 2050000, "personal": 0 }
}
tax_return (notice of assessment or return summary)
{
"kind": "tax_return", "taxpayer": "Marcus Delfino", "financialYear": "2025",
"taxableIncome": 32000000,
"incomeItems": [
{ "label": "Salary and wages", "amount": 18000000 },
{ "label": "Dividends (franked)", "amount": 2400000, "source": "Delfino Holdings Pty Ltd" },
{ "label": "Trust distributions", "amount": 9500000, "source": "Delfino Family Trust" },
{ "label": "Rental income", "amount": 3600000, "propertyHint": "Bulimba QLD" }
],
"deductions": [{ "label": "Rental property expenses", "amount": 1500000 }],
"entitiesMentioned": [
{ "name": "Delfino Holdings Pty Ltd", "role": "shareholder" },
{ "name": "Delfino Family Trust", "role": "beneficiary" }
],
"hasBusinessSchedule": true, "hasRentalSchedule": true, "hasCgtEvents": false
}
Gap and inconsistency detection
Rules are deterministic comparisons between extractions and the disclosed pool. Each produces at most one open flag per (rule, subject) pair. Severity: 1 informational, 2 should be resolved, 3 likely undisclosed asset or income.
| Rule | Kind | Sev | Condition | Suggested action |
|---|---|---|---|---|
| R1 missing documents | gap | 1 | An employed or self-employed party has no payslip or tax return; any party has no super statement; any bank_account item has no statement | Request upload from that party |
| R2 income mismatch | inconsistency | 2 | |annualisedGross - party.annualIncome| / annualIncome > 0.15, or tax return taxable income differs by more than 20% | Update income to the documented figure (party confirms) |
| R3 unknown account | undisclosed_asset | 3 | Recurring transfer to a counterpartyLast4 that matches no disclosed bank_account, mortgage or loan | Add bank_account item, unvalued, owner = uploader's counterpart per statement holders |
| R4 super not disclosed | undisclosed_asset | 3 | Payslip superFund or super statement fund has no matching superannuation item for that party | Add accumulation or smsf item with balance as valuation, source document |
| R5 balance mismatch | inconsistency | 2 | Statement closing balance or super balance differs from the party's own valuation by more than 10% and $1,000 | Offer 'use documented value' for that party's claim |
| R6 undisclosed entity | undisclosed_asset | 3 | Tax return dividends, trust distributions or entitiesMentioned with no matching business_interest, trust_interest or shares item | Add item unvalued; also suggest an ASIC discovery run |
| R7 undisclosed property | undisclosed_asset | 3 | Rental income or rental schedule with no real_property item beyond the family home, or loan repayments to a lender with no matching mortgage | Add real_property or mortgage item; suggest land titles discovery |
| R8 undisclosed income | undisclosed_income | 2 | Recurring credits categorised income from a payer not matching the party's employer, exceeding 10% of disclosed income | Ask the party to explain or update income |
Human in the loop
- Flags appear to both parties in joint mode, to the uploader only in solo mode. The party the flag concerns can resolve or dismiss; the other party sees the status. Dismissals record who and when.
- Accepting a suggestion goes through the existing domain functions (
createPoolItem,recordValuationwithsource: 'document'). The other party's claim is never touched. - Low-confidence fields (below 0.7) are shown for confirmation before any suggestion is offered.
Integration points
| Point | Detail |
|---|---|
| Tables | ph2_documents (exists), ph2_disclosure_flags (exists). related_item_id links to dc_pool_items; source_document_id links to the evidence. |
| Enum reuse | valuation_source = 'document' already exists so documented values are distinguishable from manual ones. |
| New endpoints | POST /api/matters/:id/documents, GET /api/matters/:id/documents, POST /api/matters/:id/documents/:docId/parse, DELETE /api/matters/:id/documents/:docId, GET /api/matters/:id/flags, POST /api/matters/:id/flags/:flagId/dismiss, POST /api/matters/:id/flags/:flagId/resolve, POST /api/matters/:id/documents/:docId/suggestions/:n/accept. |
| UI | Documents tab on /matter/disclosure; flag badges on affected rows; a 'Disclosure check' panel on results listing open flags as a caution. |
| Engine | None. The engine reads pool items exactly as before; flags only affect the cautions list shown next to results. |
| Tier | Parsing is part of the discovery tier; uploads and R1 are free. |
Non-goals. The AI never sets a valuation, never edits a party's claim, never decides a split, and never sees the other party's documents when extracting. It reads documents and proposes; a person decides. The settlement engine remains a published, deterministic method with no model in the loop.
Security, privacy and compliance
Australian Privacy Principles
| Principle | How Settle meets it |
|---|---|
| APP 1, 5 (open management, notification) | A plain-English privacy policy linked from every page footer and from sign-in, stating what is collected, why, where it is stored (Supabase region: Sydney), and how to delete. |
| APP 3 (collection) | Data minimisation by design: no account for the free tier; health is a boolean flag with no free text; children are first name and date of birth only; identity verification stores a reference, not documents; account numbers become last four digits in phase two. |
| APP 6 (use and disclosure) | A party's data is visible only to matter members. Estimates entered about a partner belong to the entering party and are labelled as estimates. No sale or sharing with third parties; registry queries send only the subject fields needed for the search. |
| APP 8 (cross-border) | Hosting and storage in Australia. Phase two extraction models must be hosted in-region or under a contract with equivalent protections; documents are redacted before leaving the platform. |
| APP 11 (security) | TLS everywhere, httpOnly session cookies, server-side sessions with expiry, membership checks on every matter route, RLS as defence in depth, encryption at rest, least-privilege service keys held only in Vercel environment variables. |
| APP 12, 13 (access, correction) | Users can export their snapshot as JSON and edit any of their own data at any time. |
| Deletion | Deleting a matter cascades through every prefixed table via foreign keys. Deleting an account removes memberships and sessions; matters with another remaining member are retained for that member with the leaving party's rows anonymised. Phase two objects in Storage are deleted with the document row. |
Family law and financial services boundaries
- Settle is not a legal practice and does not give legal advice. The
OUTPUT_DISCLAIMERis rendered on every result. Marketing copy uses 'modelling' and 'indicative', never 'entitlement' or 'what you will get'. - Settle does not give financial product advice. It reports balances and splits the user entered; it does not recommend funds, products or investment actions. Super split suggestions are described as an amount that 'would need to move', with the note that a splitting order requires legal process.
- The duty of full and frank disclosure belongs to the parties. Discovery and phase two flags help a party meet it and do not replace formal disclosure or court processes.
- Family violence: the invitation flow never reveals the inviter's location or IP; a party can leave a matter; support links to 1800RESPECT and Family Relationship Advice Line are in the footer. Sensitive data about the other party is limited to what settlement needs.
Application security notes
- Invitation tokens are UUIDs, single use, 14-day expiry, and require the accepting email to match; there is no enumeration endpoint.
- OTP codes are hashed at rest, expire in 10 minutes and are single use; rate limiting on
/api/auth/startper email and IP is required before launch. - Route handlers validate every body with zod and never trust client-supplied
agreedValue,tieroridentityVerified. - Destructive actions (remove item, revoke invitation, delete matter, delete document) require confirmation and are the only red interface elements.
Roadmap and open questions
| Phase | Scope |
|---|---|
| 1 (now) | Domain contexts, engine 1.0.0, split modeller, snapshot read model, schema and migration 0000, demo scenarios, mock discovery, local and Supabase auth providers, API contract, App Router pages. |
| 1.5 hardening | RLS policies migration; rate limiting; disable the demo login endpoint in production; bearer-token auth for native clients; Stripe Checkout for the discovery tier; transactional email for magic links and invitations; JSON export and a printable lawyer summary; legal review of TUNING and factor copy. |
| 1.6 native | Extract src/core to packages/core, add bearer-token auth, scaffold the Expo app (Section 12.1). |
| 2 documents | Upload, parsing worker, extraction schemas, checker rules R1 to R8, flags UI, human-in-the-loop, privacy review of the extraction vendor. |
| 3 real discovery | ASIC and PPSR adapters via a broker; land titles aggregator for QLD, NSW and VIC first; DVS-backed identity vendor; ATO super guidance flow (user's own accounts by consent). |
| Later | Parenting plan drafting aid; consent order preparation checklist for lawyers; mediator and lawyer sharing links; WA de facto specifics. |
Open questions
| Question | Notes |
|---|---|
| Product name | 'Settle' is a working title. Trade mark search and domain availability needed; rename is a one-line change in brand.ts. |
| Pricing | Per-matter one-off versus subscription; whether registry fees pass through; whether both parties can share one discovery purchase. Depends on real registry costs. |
| ATO super access | No third-party API exists for another person's super. The honest product is guidance for the user's own lookup plus a disclosure request template for the partner's. Decide how prominently to market this provider. |
| Land titles name search | Not offered in every jurisdiction and fee-bearing. Which states at launch, and whether a broker relationship is viable at the target price. |
| PPSR permitted purpose | Individual grantor searches are restricted; legal advice needed on whether property settlement is a permitted purpose for a platform acting for a user. |
| Mortgages when properties are split | Resolved for the common case: a joint mortgage follows the party who keeps the property (Section 8.3). Still open: when each party keeps a different property, the modeller falls back to sale-and-share for joint loans; a per-loan link to its secured property would be more precise. |
| Defined benefit super | The category exists but values are entered as a lump sum. Whether to add a family law valuation helper. |
| De facto thresholds | The engine does not check the two-year or child-of-the-relationship gateway for de facto claims, nor the two-year limitation after separation. Decide whether to warn. |
| Legal review of constants | All TUNING values and factor bands need family law practitioner review before public launch; version bump on change. |
| Analytics | None included. If added, privacy-preserving and self-hosted, never on matter pages with financial data in URLs. |
Appendix
A. Legal and domain glossary
| s79 / s90SM | Family Law Act 1975 provisions for altering property interests of married and de facto couples respectively. |
| s75(2) / s79(5) factors | The current and future circumstances a court considers (income, earning capacity, care of children, age, health). The engine's step 3. |
| Just and equitable | The overriding requirement that any order be fair in all the circumstances. The engine's step 4. |
| Full and frank disclosure | Each party's duty to disclose all relevant financial information. What the disclosure context and phase two support. |
| Superannuation splitting order | A court order or agreement that transfers part of one party's super to the other. What superSplitSuggestion describes. |
| Binding financial agreement | A private agreement requiring independent legal advice for each party. Referenced in the disclaimer. |
| Consent orders | Court orders made by agreement without a hearing. Out of scope; the lawyer prepares them. |
| Division 7A loan | A loan from a private company to a shareholder or associate, treated as a debt to the company. Appears in the complex demo as a tax_debt. |
| PPSR | Personal Property Securities Register; records security interests such as car finance. |
| SMSF | Self-managed superannuation fund. |
| Care percentage | Services Australia's measure of nights of care per year, banded into below regular, regular, shared, primary and above primary. |
B. Environment variables
| Variable | Purpose | Default |
|---|---|---|
DATABASE_URL | Postgres connection string (Supabase pooled string in production) | postgres://postgres:postgres@localhost:5432/settle_dev |
NEXT_PUBLIC_APP_URL | Public origin, used for magic link redirects and accept URLs | http://localhost:3000 |
NEXT_PUBLIC_SUPABASE_URL | Switches auth to Supabase when set together with the anon key | unset (local auth) |
NEXT_PUBLIC_SUPABASE_ANON_KEY | Supabase anon key for the auth client | unset |
DEMO_ENABLED | Enables /api/demo/* (scenario list and demo login). Must be unset or false in production; the handlers return 404 when disabled | true in development |
NODE_ENV | isProduction() toggles secure cookies and log verbosity | development |
SUPABASE_SERVICE_ROLE_KEY Phase two | Server-side Storage access for documents | unset |
DOCUMENTS_BUCKET, EXTRACTION_MODEL, QUEUE_URL Phase two | Parsing pipeline configuration | unset |
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET Later | Real payments for the discovery tier | unset |
C. Repository layout
src/
contexts/ bounded contexts (DDD). No context imports another's infrastructure.
shared/domain Money (integer cents), Result, ids, dates
identity/ users, sessions, tiers, invitations; AuthProvider port
infrastructure/ LocalAuthProvider (dev: code 123456), SupabaseAuthProvider (prod)
disclosure/domain Matter, Party, Child, PoolItem with per-party valuations, agreedValue rule
parenting/domain CareArrangement, care %, cost %, primary carer
settlement-modelling/ assessSettlement (4-step, deterministic), modelSplit, standardScenarios
asset-discovery/ domain (port, gating), application (runDiscovery), infrastructure (mock-providers)
read-models/matter-snapshot MatterSnapshot = whole matter as one JSON aggregate; computeResults()
demo/scenarios six demo scenarios built via domain constructors (deterministic ids)
db/ Drizzle schema (prefixes id_, dc_, pa_, ad_, ph2_), client, MatterRepository
lib/ brand + theme tokens, server helpers (env, http, repositories)
app/ Next.js App Router (UI + /api route handlers)
drizzle/ 0000_calm_triathlon.sql and meta
tests/unit, tests/integration, tests/e2e
docs/ARCHITECTURE.md, docs/spec/
D. Document conventions
Straight quotes throughout. Money in the prose is whole dollars; money in code and the database is integer cents. 'A' and 'B' are roles, never genders. Dates are ISO in code and Australian in prose. This document was generated against the repository state on 16 September 2026 and should be regenerated when ENGINE_VERSION, the schema, or the API table changes.