Vibecode Portfolio Coach
track this build4 phases, 12 steps, beginner friendly0%A private dashboard for holdings, family members, projections, and CSV imports is a credible weekend build. Reliable live pricing for UK funds, a ledger-correct Vanguard importer, household data boundaries, maintained tax rules, and AI advice grounded in every account turn the complete product into a multi-day project.
You are building a lean indie version of Portfolio Coach. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Portfolio Coach · indie build A household tracker whose hard problems are deriving positions from a transaction ledger you can reconcile, never displaying a valuation without its source and age, and keeping a model review on the describing side of a regulated line. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js with TypeScript and SQLite, bound to localhost | a household's entire financial position should not need a server somebody else operates | | Ledger | Transactions are the record; positions are derived and rebuildable | a position you typed in cannot be reconciled against a statement | | Money | Integer minor units with a currency code, and every rate stored with its date | a portfolio in four currencies compared in floating point is wrong by an amount nobody can find | | Rules | A dated tax rules file with a source and a review date, checked at render time | frozen thresholds get extended, and an extended threshold is a silently wrong number | | Model | One call that describes and asks questions, with a refusal check on the output | the line between describing a portfolio and advising on it is a regulated one | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the tracker locally. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **Real statements and transaction exports for at least two accounts** · free - Why: Reconciliation is the only thing that proves the ledger is right, and it needs a figure produced by somebody other than your own code. - Get it: Download transaction history and a valuation statement from the same date for two accounts. - Verify: Derived positions match the statement to the penny - [ ] **A dated tax rules file you compiled yourself** · free - Why: Every figure on the planning page comes from it, and a figure with no source and no review date is a number that will be quietly wrong. - Get it: Read the current published thresholds on the revenue authority's own pages, record the value, the tax year, the source link and the date you checked, and set a review date. - Verify: Every row has a source, a checked date and a review date, and a test asserts it - [ ] **Written description of what the software will not say** · free - Why: A tracker with a model attached drifts towards advice one helpful feature at a time, and the boundary has to exist before the first review is generated. - Get it: Write down that no output names an instrument to buy, sell, hold or switch, that no segment-based suggestion is produced, and what the output check refuses. - Verify: The adversarial prompt test enforces the same list the README states - [ ] **An API key for a language model** (optional) · pennies per review - Why: The review is the only part that leaves the machine, and it should be the only part with a spend limit and a provider-side ceiling. - Get it: Create a key with your preferred provider and set a spend limit in their console. - Verify: A review runs and the payload snapshot test passes ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Any suggestion about a particular investment. - Segment-based suggestions. - Broker and bank connections. - A definitive tax calculation. - reliable live pricing and UK fund symbol mapping - ledger-backed Vanguard history import - secure cloud accounts, family sharing, and support access - maintained UK tax and estate-planning assumptions - hosted AI reviews with current web context If one of those is essential to you, that is the reason to keep paying for Portfolio Coach, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Portfolio Coach The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a private household portfolio tracker that describes what you hold and refuses to suggest what to do about it to replace Portfolio Coach. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero This one has a regulator, and the boundary is not where most builders assume it is. In the United Kingdom, article 53 of the Regulated Activities Order makes advising a specified activity where the advice is given to a person 'in his capacity as an investor or potential investor' and is 'advice on the merits' of buying, selling, holding or exchanging 'a particular investment'. Two elements: the merits, and a particular investment. The tempting move is to conclude that anything impersonal is therefore safe. That argument has just been narrowed from the other direction. A new article 55A, inserted with effect from 2026, created a regulated activity called providing targeted support, and the regulator's own rules describe it as delivering 'a ready-made suggestion by reference to the client's alignment with a pre-defined consumer segment', at scale, on limited information. Firms that wanted to make segment-level suggestions needed a permission created for the purpose. Checked at legislation.gov.uk and the regulator's policy statement on 15 September 2026. So the line this build draws is not about personalisation. It is: this tool describes, and does not suggest. It reports what the household holds, what it is worth, how concentrated it is, and what a projection does under assumptions the user typed in themselves. It never names an instrument to buy, sell, hold or switch, and there is no table it could store such a thing in. Second, the numbers go stale by being extended rather than by changing. The annual ISA subscription limit is 20,000 pounds for 2026 to 2027, and the inheritance tax nil rate band is 325,000 pounds with a residence band of 175,000 and a taper starting at 2 million, held there by successive Finance Acts and currently to 2030 to 2031. Checked on gov.uk on 15 September 2026. Every such figure lives in a dated rules file with a review date, and the planning page shows nothing rather than an old number once that date has passed. ### Stack (fixed, do not substitute) - Runtime: Next.js with TypeScript and SQLite, bound to localhost · a household's entire financial position should not need a server somebody else operates - Ledger: Transactions are the record; positions are derived and rebuildable · a position you typed in cannot be reconciled against a statement - Money: Integer minor units with a currency code, and every rate stored with its date · a portfolio in four currencies compared in floating point is wrong by an amount nobody can find - Rules: A dated tax rules file with a source and a review date, checked at render time · frozen thresholds get extended, and an extended threshold is a silently wrong number - Model: One call that describes and asks questions, with a refusal check on the output · the line between describing a portfolio and advising on it is a regulated one ### Data model (create this before Phase 1) - people(id, name, relationship) and accounts(id, person_id, wrapper, provider, currency, opened_on) - transactions(id, account_id, at, kind, instrument_id, units, price_minor, price_currency, fee_minor, fx_rate, fx_rate_date, source_row) · the record everything else is derived from - instruments(id, symbol, name, currency, kind, identifier_kind, identifier) · the identifier kind matters because the same fund answers to several - prices(instrument_id, as_of, price_minor, currency, source) · a price with no source and no date is not shown - positions_cache(account_id, instrument_id, as_of, units, book_cost_minor) · derived, rebuildable, never authoritative - assumptions(id, name, value, unit, set_by, set_at) · every projection input, attributed to the person who typed it - tax_rules(id, jurisdiction, name, value_minor, tax_year, source_url, checked_on, review_by) · the planning page refuses once the review date has passed - reviews(id, at, model_id, prompt_version, input_summary_json, output_text, refusals_json) · what was sent and what came back, because 'the app told me to' needs an answer - There is no table for a recommendation, a target allocation or a suggested trade. There is nowhere to put one, and that absence is the design. ### Phase 1 · A ledger you can reconcile, not a list of holdings Build: Model transactions with units, price, fee, and a conversion rate with its date. Derive positions and book cost. Import CSV with saved column mappings and a stable row hash. Add a reconciliation screen that compares derived units and value against figures typed from a statement. Trap: Letting someone type today's holdings and calling it a portfolio leaves no cost basis, no history and nothing to check against anything, so every gain figure is a guess and a double import goes unnoticed for a year. Store transactions, derive positions, and put a reconciliation screen in front of the dashboard so a mismatch is visible on the day it appears rather than at the next tax year. Done when: Derived positions match a reconciliation figure for a fixture account to the penny, tested. Importing the same file twice adds nothing, tested against the row hash. A transaction with a fee in one currency and a price in another produces a book cost that can be checked by hand, with the working shown. No position is directly editable anywhere in the interface, verified by review. Do not build yet: No pricing, projections, tax rules or model review yet. ### Phase 2 · No valuation without its source and its age Build: Support manual prices first and an optional feed second. Store source and as-of on every price. Mark anything past a configured age as stale and display the oldest price's age beside the household total. Keep the whole application working with the feed switched off. Trap: Falling back to the last known price when a feed fails makes a portfolio valued on Friday look as though it was valued this morning, and the first time anybody notices is when a figure somebody acted on turns out to be a fortnight old. Stamp every price with its source and time, mark stale ones visibly, and put the age of the oldest price next to the headline total rather than behind a hover. Done when: No valuation renders anywhere without its source and as-of time, verified by review and a test over the rendered pages. A stale price is visibly marked and the headline total carries the age of the oldest price behind it. With the feed disabled the application values everything from manual prices and says so. A price fetch failure never overwrites or extends an existing price row. Do not build yet: No projections, planning page or model review yet. ### Phase 3 · Projections made only of assumptions somebody typed Build: Make every projection input explicit: contributions, growth, inflation, withdrawals, retirement age, planned gifts. Attribute each to the person who entered it and the time. Render at least three scenarios as a band, never a single line. Ship no default growth rate at all. Trap: Shipping a default growth rate means the chart somebody screenshots and shows their partner was produced by a number your application chose, and every conversation afterwards is about a figure nobody in the room decided. Ship none: leave the projection page empty until the assumptions are entered, attribute each one, and carry them on the face of the chart rather than in a panel underneath. Done when: The projection refuses to render until every assumption has a value, tested by omitting each in turn. The output is a band from at least three scenarios and there is no code path that draws a single line, verified by review. Changing an assumption records who changed it and when. The assumptions appear on any exported image or file, tested by inspecting an export. Do not build yet: No tax rules, model review or household access boundaries yet. ### Phase 4 · A planning page that expires Build: Put every threshold and allowance in a dated rules file with a jurisdiction, a tax year, a source link, a checked date and a review date. Render the planning page from it. Once the review date has passed, show no figure at all and name what to check and where. Label the page informational in the same component that renders the number, not in a footer. Trap: Hard-coding a threshold that has been frozen for years feels safe precisely because it has not moved, and then a Finance Act extends the freeze again and your figure is right for the wrong reason until the year it silently is not. Put every figure in a dated file with its source, let the page refuse once the review date passes, and make updating the file a new row rather than an edit so an old estimate can still be explained. Done when: Every rule row has a source link, a checked date and a review date, enforced by a not-null constraint and a test. With the clock advanced past a review date, the planning page shows no figure and names the source to check, tested. Updating a figure adds a row and leaves the old one, tested. The informational label renders in the same component as the figure and cannot be displayed without it. Do not build yet: No model review or household access boundaries yet. ### Phase 5 · The line the software will not cross Build: Send only a computed summary to the model: aggregates, weights and the assumptions, with no names, account numbers or transaction rows. Constrain the prompt to describing and asking. Check the output for instrument-level imperatives before it renders and refuse rather than edit. Store every review with its prompt version and refusals. Add per-person access so a household member sees only the accounts they are linked to, and an encrypted export of everything. Trap: An open-ended 'review my portfolio' prompt returns a suggestion about a specific holding roughly every other time, because that is what a portfolio review looks like in the material the model learned from, and once it is on the screen the household has received something that reads exactly like advice from a tool sitting in their own house. Keep an adversarial prompt set in the suite, check the output before it renders, and refuse the whole response rather than deleting the offending sentence. Done when: A stored set of adversarial prompts produces no output naming a buy, sell, hold or switch of a specific instrument, run as a test. The payload sent to the model contains no names, account identifiers or transaction rows, asserted against a snapshot. Every review stores its prompt version, the summary sent and any refusals. A household member signing in sees only accounts they are linked to, tested with two people, and the encrypted export restores into an empty database. Do not build yet: Nothing after this. ### Out of scope (and why) - Any suggestion about a particular investment, which is the element article 53 turns on and the reason the schema has nowhere to store one. - Segment-based suggestions of the 'people like you usually' shape, which is precisely what the 2026 targeted support regime made a permissioned activity rather than a clever workaround. - Broker and bank connections, which mean holding credentials to other people's money and are a different category of responsibility from a tracker. - A definitive tax calculation. The planning page is an indicative figure from a dated file, and it stops showing anything once that file is out of date. ### README must contain - The two elements of article 53, quoted, with the source and the date checked, and the statement that this tool describes rather than suggests - That segment-level suggestions became a separately permissioned activity in 2026, so 'it is generic, therefore it is fine' is not the safe harbour it looks like - That every tax figure carries a source, a checked date and a review date, and that the page shows nothing once that date has passed - That positions are derived from transactions and that the reconciliation screen is how you check them - That the model receives aggregates only, and what the adversarial prompt test checks for ===== AGENTS.md ===== # Agent instructions · Portfolio Coach indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js with TypeScript and SQLite, bound to localhost, Transactions are the record; positions are derived and rebuildable, Integer minor units with a currency code, and every rate stored with its date, A dated tax rules file with a source and a review date, checked at render time, One call that describes and asks questions, with a refusal check on the output. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Letting someone type today's holdings and calling it a portfolio leaves no cost basis, no history and nothing to check against anything, so every gain figure is a guess and a double import goes unnoticed for a year. Store transactions, derive positions, and put a reconciliation screen in front of the dashboard so a mismatch is visible on the day it appears rather than at the next tax year. - Falling back to the last known price when a feed fails makes a portfolio valued on Friday look as though it was valued this morning, and the first time anybody notices is when a figure somebody acted on turns out to be a fortnight old. Stamp every price with its source and time, mark stale ones visibly, and put the age of the oldest price next to the headline total rather than behind a hover. - Shipping a default growth rate means the chart somebody screenshots and shows their partner was produced by a number your application chose, and every conversation afterwards is about a figure nobody in the room decided. Ship none: leave the projection page empty until the assumptions are entered, attribute each one, and carry them on the face of the chart rather than in a panel underneath. - Hard-coding a threshold that has been frozen for years feels safe precisely because it has not moved, and then a Finance Act extends the freeze again and your figure is right for the wrong reason until the year it silently is not. Put every figure in a dated file with its source, let the page refuse once the review date passes, and make updating the file a new row rather than an edit so an old estimate can still be explained. ===== BUILD_PLAN.md ===== # Build plan · Portfolio Coach A household tracker whose hard problems are deriving positions from a transaction ledger you can reconcile, never displaying a valuation without its source and age, and keeping a model review on the describing side of a regulated line. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A ledger you can reconcile, not a list of holdings Positions are derived from transactions and can be checked against a real statement. ### Steps 1. Make transactions the only writable record Everything else is a view and should be deletable without loss. Files: `src/db/schema.sql`, `src/positions.ts` 2. Put reconciliation in front of the dashboard A pretty number nobody has checked is worse than no number. Files: `src/app/reconcile/page.tsx`, `tests/reconcile.test.ts` 3. Store the conversion rate with its date on the transaction The rate on the day is part of the transaction, not a lookup. Files: `src/import/map.ts` ### Done when - [ ] Derived positions match a statement fixture exactly - [ ] A repeated import adds nothing - [ ] A multi-currency transaction shows its working - [ ] No position can be edited directly ### Watch out - Letting someone type today's holdings and calling it a portfolio leaves no cost basis, no history and nothing to check against anything, so every gain figure is a guess and a double import goes unnoticed for a year. Store transactions, derive positions, and put a reconciliation screen in front of the dashboard so a mismatch is visible on the day it appears rather than at the next tax year. ## Phase 2 · No valuation without its source and its age Every figure on the screen can be traced to a price, a source and a time. ### Steps 1. Stamp source and as-of on every price row A price without provenance is a rumour with two decimal places. Files: `src/prices/store.ts` 2. Put the oldest price age beside the headline It is the one number that tells you whether to trust the others. Files: `src/app/page.tsx` 3. Keep the whole thing working with the feed off Manual prices are the floor, not the fallback. Files: `src/prices/manual.ts`, `tests/no-feed.test.ts` ### Done when - [ ] Every valuation shows source and as-of - [ ] Stale prices are marked and the oldest age is on the headline - [ ] The app values everything with the feed disabled - [ ] A failed fetch never extends an existing price ### Watch out - Falling back to the last known price when a feed fails makes a portfolio valued on Friday look as though it was valued this morning, and the first time anybody notices is when a figure somebody acted on turns out to be a fortnight old. Stamp every price with its source and time, mark stale ones visibly, and put the age of the oldest price next to the headline total rather than behind a hover. ## Phase 3 · Projections made only of assumptions somebody typed The chart cannot be drawn until every input has been supplied and attributed. ### Steps 1. Ship no default growth rate An unowned number becomes everyone's number. Files: `src/projection/inputs.ts` 2. Render a band, never a line A single line is a forecast and you do not have one. Files: `src/projection/chart.tsx` 3. Attribute every assumption and carry them on the chart So the screenshot arrives with its own footnotes. Files: `src/projection/assumptions.ts`, `tests/projection.test.ts` ### Done when - [ ] The projection refuses to render with a missing input - [ ] No path draws a single-line projection - [ ] Assumptions record who set them and when - [ ] Exports carry the assumptions ### Watch out - Shipping a default growth rate means the chart somebody screenshots and shows their partner was produced by a number your application chose, and every conversation afterwards is about a figure nobody in the room decided. Ship none: leave the projection page empty until the assumptions are entered, attribute each one, and carry them on the face of the chart rather than in a panel underneath. ## Phase 4 · A planning page that expires Every tax figure has a source, a checked date and a review date, and the page stops rather than ages. ### Steps 1. Keep every figure in a dated rules file with a review date The figure and its expiry are one fact. Files: `data/rules/uk-2026.json`, `src/rules.ts` 2. Let the page refuse rather than render an old number Showing nothing is honest; showing last year's threshold is not. Files: `src/app/planning/page.tsx`, `tests/expiry.test.ts` 3. Bind the label to the figure in one component A disclaimer in the footer is a disclaimer nobody reads. Files: `src/components/TaxFigure.tsx` ### Done when - [ ] Every rule has source, checked date and review date - [ ] A passed review date hides the figure and names the source - [ ] Updates add rows rather than editing them - [ ] The label cannot render without the figure ### Watch out - Hard-coding a threshold that has been frozen for years feels safe precisely because it has not moved, and then a Finance Act extends the freeze again and your figure is right for the wrong reason until the year it silently is not. Put every figure in a dated file with its source, let the page refuse once the review date passes, and make updating the file a new row rather than an edit so an old estimate can still be explained. ## Not in this build - Any suggestion about a particular investment. - Segment-based suggestions. - Broker and bank connections. - A definitive tax calculation. ## After v1, if you want it - Corporate actions handled as transactions, since a share split silently breaks every derived position - A second jurisdiction's rules file, which is the real test of whether the rules abstraction holds - A dated snapshot of the whole household position, so a projection made last year can be scored against what happened ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. The ledger, the prices, the assumptions and the reviews. Back it up properly. DATABASE_PATH=./data/portfolio.db # Required. Dated tax rules files. Updates add rows; nothing is edited in place. RULES_PATH=./data/rules # Required. Past this a price is marked stale and its age appears on the headline. PRICE_STALE_HOURS=48 # Optional · secret. Optional. With it absent the application values everything from manual prices. PRICE_FEED_KEY=pk_...redacted # Optional · secret. The review. The only outbound call, and the only thing with a spend ceiling. MODEL_API_KEY=sk-...redacted # Required. Reviews stop at this figure. The tracker carries on without them. MODEL_MONTHLY_CEILING=2.00 # Required · secret. Encrypts the export. Losing it loses the export, not the database. EXPORT_PASSPHRASE=a-long-passphrase-redacted # Required. Everything is converted into this for totals, with the rate and its date stored. BASE_CURRENCY=GBP
You are building a lean indie version of Portfolio Coach. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Portfolio Coach · indie build A household tracker whose hard problems are deriving positions from a transaction ledger you can reconcile, never displaying a valuation without its source and age, and keeping a model review on the describing side of a regulated line. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js with TypeScript and SQLite, bound to localhost | a household's entire financial position should not need a server somebody else operates | | Ledger | Transactions are the record; positions are derived and rebuildable | a position you typed in cannot be reconciled against a statement | | Money | Integer minor units with a currency code, and every rate stored with its date | a portfolio in four currencies compared in floating point is wrong by an amount nobody can find | | Rules | A dated tax rules file with a source and a review date, checked at render time | frozen thresholds get extended, and an extended threshold is a silently wrong number | | Model | One call that describes and asks questions, with a refusal check on the output | the line between describing a portfolio and advising on it is a regulated one | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the tracker locally. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **Real statements and transaction exports for at least two accounts** · free - Why: Reconciliation is the only thing that proves the ledger is right, and it needs a figure produced by somebody other than your own code. - Get it: Download transaction history and a valuation statement from the same date for two accounts. - Verify: Derived positions match the statement to the penny - [ ] **A dated tax rules file you compiled yourself** · free - Why: Every figure on the planning page comes from it, and a figure with no source and no review date is a number that will be quietly wrong. - Get it: Read the current published thresholds on the revenue authority's own pages, record the value, the tax year, the source link and the date you checked, and set a review date. - Verify: Every row has a source, a checked date and a review date, and a test asserts it - [ ] **Written description of what the software will not say** · free - Why: A tracker with a model attached drifts towards advice one helpful feature at a time, and the boundary has to exist before the first review is generated. - Get it: Write down that no output names an instrument to buy, sell, hold or switch, that no segment-based suggestion is produced, and what the output check refuses. - Verify: The adversarial prompt test enforces the same list the README states - [ ] **An API key for a language model** (optional) · pennies per review - Why: The review is the only part that leaves the machine, and it should be the only part with a spend limit and a provider-side ceiling. - Get it: Create a key with your preferred provider and set a spend limit in their console. - Verify: A review runs and the payload snapshot test passes ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Any suggestion about a particular investment. - Segment-based suggestions. - Broker and bank connections. - A definitive tax calculation. - reliable live pricing and UK fund symbol mapping - ledger-backed Vanguard history import - secure cloud accounts, family sharing, and support access - maintained UK tax and estate-planning assumptions - hosted AI reviews with current web context If one of those is essential to you, that is the reason to keep paying for Portfolio Coach, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Portfolio Coach The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a private household portfolio tracker that describes what you hold and refuses to suggest what to do about it to replace Portfolio Coach. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero This one has a regulator, and the boundary is not where most builders assume it is. In the United Kingdom, article 53 of the Regulated Activities Order makes advising a specified activity where the advice is given to a person 'in his capacity as an investor or potential investor' and is 'advice on the merits' of buying, selling, holding or exchanging 'a particular investment'. Two elements: the merits, and a particular investment. The tempting move is to conclude that anything impersonal is therefore safe. That argument has just been narrowed from the other direction. A new article 55A, inserted with effect from 2026, created a regulated activity called providing targeted support, and the regulator's own rules describe it as delivering 'a ready-made suggestion by reference to the client's alignment with a pre-defined consumer segment', at scale, on limited information. Firms that wanted to make segment-level suggestions needed a permission created for the purpose. Checked at legislation.gov.uk and the regulator's policy statement on 15 September 2026. So the line this build draws is not about personalisation. It is: this tool describes, and does not suggest. It reports what the household holds, what it is worth, how concentrated it is, and what a projection does under assumptions the user typed in themselves. It never names an instrument to buy, sell, hold or switch, and there is no table it could store such a thing in. Second, the numbers go stale by being extended rather than by changing. The annual ISA subscription limit is 20,000 pounds for 2026 to 2027, and the inheritance tax nil rate band is 325,000 pounds with a residence band of 175,000 and a taper starting at 2 million, held there by successive Finance Acts and currently to 2030 to 2031. Checked on gov.uk on 15 September 2026. Every such figure lives in a dated rules file with a review date, and the planning page shows nothing rather than an old number once that date has passed. ### Stack (fixed, do not substitute) - Runtime: Next.js with TypeScript and SQLite, bound to localhost · a household's entire financial position should not need a server somebody else operates - Ledger: Transactions are the record; positions are derived and rebuildable · a position you typed in cannot be reconciled against a statement - Money: Integer minor units with a currency code, and every rate stored with its date · a portfolio in four currencies compared in floating point is wrong by an amount nobody can find - Rules: A dated tax rules file with a source and a review date, checked at render time · frozen thresholds get extended, and an extended threshold is a silently wrong number - Model: One call that describes and asks questions, with a refusal check on the output · the line between describing a portfolio and advising on it is a regulated one ### Data model (create this before Phase 1) - people(id, name, relationship) and accounts(id, person_id, wrapper, provider, currency, opened_on) - transactions(id, account_id, at, kind, instrument_id, units, price_minor, price_currency, fee_minor, fx_rate, fx_rate_date, source_row) · the record everything else is derived from - instruments(id, symbol, name, currency, kind, identifier_kind, identifier) · the identifier kind matters because the same fund answers to several - prices(instrument_id, as_of, price_minor, currency, source) · a price with no source and no date is not shown - positions_cache(account_id, instrument_id, as_of, units, book_cost_minor) · derived, rebuildable, never authoritative - assumptions(id, name, value, unit, set_by, set_at) · every projection input, attributed to the person who typed it - tax_rules(id, jurisdiction, name, value_minor, tax_year, source_url, checked_on, review_by) · the planning page refuses once the review date has passed - reviews(id, at, model_id, prompt_version, input_summary_json, output_text, refusals_json) · what was sent and what came back, because 'the app told me to' needs an answer - There is no table for a recommendation, a target allocation or a suggested trade. There is nowhere to put one, and that absence is the design. ### Phase 1 · A ledger you can reconcile, not a list of holdings Build: Model transactions with units, price, fee, and a conversion rate with its date. Derive positions and book cost. Import CSV with saved column mappings and a stable row hash. Add a reconciliation screen that compares derived units and value against figures typed from a statement. Trap: Letting someone type today's holdings and calling it a portfolio leaves no cost basis, no history and nothing to check against anything, so every gain figure is a guess and a double import goes unnoticed for a year. Store transactions, derive positions, and put a reconciliation screen in front of the dashboard so a mismatch is visible on the day it appears rather than at the next tax year. Done when: Derived positions match a reconciliation figure for a fixture account to the penny, tested. Importing the same file twice adds nothing, tested against the row hash. A transaction with a fee in one currency and a price in another produces a book cost that can be checked by hand, with the working shown. No position is directly editable anywhere in the interface, verified by review. Do not build yet: No pricing, projections, tax rules or model review yet. ### Phase 2 · No valuation without its source and its age Build: Support manual prices first and an optional feed second. Store source and as-of on every price. Mark anything past a configured age as stale and display the oldest price's age beside the household total. Keep the whole application working with the feed switched off. Trap: Falling back to the last known price when a feed fails makes a portfolio valued on Friday look as though it was valued this morning, and the first time anybody notices is when a figure somebody acted on turns out to be a fortnight old. Stamp every price with its source and time, mark stale ones visibly, and put the age of the oldest price next to the headline total rather than behind a hover. Done when: No valuation renders anywhere without its source and as-of time, verified by review and a test over the rendered pages. A stale price is visibly marked and the headline total carries the age of the oldest price behind it. With the feed disabled the application values everything from manual prices and says so. A price fetch failure never overwrites or extends an existing price row. Do not build yet: No projections, planning page or model review yet. ### Phase 3 · Projections made only of assumptions somebody typed Build: Make every projection input explicit: contributions, growth, inflation, withdrawals, retirement age, planned gifts. Attribute each to the person who entered it and the time. Render at least three scenarios as a band, never a single line. Ship no default growth rate at all. Trap: Shipping a default growth rate means the chart somebody screenshots and shows their partner was produced by a number your application chose, and every conversation afterwards is about a figure nobody in the room decided. Ship none: leave the projection page empty until the assumptions are entered, attribute each one, and carry them on the face of the chart rather than in a panel underneath. Done when: The projection refuses to render until every assumption has a value, tested by omitting each in turn. The output is a band from at least three scenarios and there is no code path that draws a single line, verified by review. Changing an assumption records who changed it and when. The assumptions appear on any exported image or file, tested by inspecting an export. Do not build yet: No tax rules, model review or household access boundaries yet. ### Phase 4 · A planning page that expires Build: Put every threshold and allowance in a dated rules file with a jurisdiction, a tax year, a source link, a checked date and a review date. Render the planning page from it. Once the review date has passed, show no figure at all and name what to check and where. Label the page informational in the same component that renders the number, not in a footer. Trap: Hard-coding a threshold that has been frozen for years feels safe precisely because it has not moved, and then a Finance Act extends the freeze again and your figure is right for the wrong reason until the year it silently is not. Put every figure in a dated file with its source, let the page refuse once the review date passes, and make updating the file a new row rather than an edit so an old estimate can still be explained. Done when: Every rule row has a source link, a checked date and a review date, enforced by a not-null constraint and a test. With the clock advanced past a review date, the planning page shows no figure and names the source to check, tested. Updating a figure adds a row and leaves the old one, tested. The informational label renders in the same component as the figure and cannot be displayed without it. Do not build yet: No model review or household access boundaries yet. ### Phase 5 · The line the software will not cross Build: Send only a computed summary to the model: aggregates, weights and the assumptions, with no names, account numbers or transaction rows. Constrain the prompt to describing and asking. Check the output for instrument-level imperatives before it renders and refuse rather than edit. Store every review with its prompt version and refusals. Add per-person access so a household member sees only the accounts they are linked to, and an encrypted export of everything. Trap: An open-ended 'review my portfolio' prompt returns a suggestion about a specific holding roughly every other time, because that is what a portfolio review looks like in the material the model learned from, and once it is on the screen the household has received something that reads exactly like advice from a tool sitting in their own house. Keep an adversarial prompt set in the suite, check the output before it renders, and refuse the whole response rather than deleting the offending sentence. Done when: A stored set of adversarial prompts produces no output naming a buy, sell, hold or switch of a specific instrument, run as a test. The payload sent to the model contains no names, account identifiers or transaction rows, asserted against a snapshot. Every review stores its prompt version, the summary sent and any refusals. A household member signing in sees only accounts they are linked to, tested with two people, and the encrypted export restores into an empty database. Do not build yet: Nothing after this. ### Out of scope (and why) - Any suggestion about a particular investment, which is the element article 53 turns on and the reason the schema has nowhere to store one. - Segment-based suggestions of the 'people like you usually' shape, which is precisely what the 2026 targeted support regime made a permissioned activity rather than a clever workaround. - Broker and bank connections, which mean holding credentials to other people's money and are a different category of responsibility from a tracker. - A definitive tax calculation. The planning page is an indicative figure from a dated file, and it stops showing anything once that file is out of date. ### README must contain - The two elements of article 53, quoted, with the source and the date checked, and the statement that this tool describes rather than suggests - That segment-level suggestions became a separately permissioned activity in 2026, so 'it is generic, therefore it is fine' is not the safe harbour it looks like - That every tax figure carries a source, a checked date and a review date, and that the page shows nothing once that date has passed - That positions are derived from transactions and that the reconciliation screen is how you check them - That the model receives aggregates only, and what the adversarial prompt test checks for ===== AGENTS.md ===== # Agent instructions · Portfolio Coach indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js with TypeScript and SQLite, bound to localhost, Transactions are the record; positions are derived and rebuildable, Integer minor units with a currency code, and every rate stored with its date, A dated tax rules file with a source and a review date, checked at render time, One call that describes and asks questions, with a refusal check on the output. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Letting someone type today's holdings and calling it a portfolio leaves no cost basis, no history and nothing to check against anything, so every gain figure is a guess and a double import goes unnoticed for a year. Store transactions, derive positions, and put a reconciliation screen in front of the dashboard so a mismatch is visible on the day it appears rather than at the next tax year. - Falling back to the last known price when a feed fails makes a portfolio valued on Friday look as though it was valued this morning, and the first time anybody notices is when a figure somebody acted on turns out to be a fortnight old. Stamp every price with its source and time, mark stale ones visibly, and put the age of the oldest price next to the headline total rather than behind a hover. - Shipping a default growth rate means the chart somebody screenshots and shows their partner was produced by a number your application chose, and every conversation afterwards is about a figure nobody in the room decided. Ship none: leave the projection page empty until the assumptions are entered, attribute each one, and carry them on the face of the chart rather than in a panel underneath. - Hard-coding a threshold that has been frozen for years feels safe precisely because it has not moved, and then a Finance Act extends the freeze again and your figure is right for the wrong reason until the year it silently is not. Put every figure in a dated file with its source, let the page refuse once the review date passes, and make updating the file a new row rather than an edit so an old estimate can still be explained. ===== BUILD_PLAN.md ===== # Build plan · Portfolio Coach A household tracker whose hard problems are deriving positions from a transaction ledger you can reconcile, never displaying a valuation without its source and age, and keeping a model review on the describing side of a regulated line. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A ledger you can reconcile, not a list of holdings Positions are derived from transactions and can be checked against a real statement. ### Steps 1. Make transactions the only writable record Everything else is a view and should be deletable without loss. Files: `src/db/schema.sql`, `src/positions.ts` 2. Put reconciliation in front of the dashboard A pretty number nobody has checked is worse than no number. Files: `src/app/reconcile/page.tsx`, `tests/reconcile.test.ts` 3. Store the conversion rate with its date on the transaction The rate on the day is part of the transaction, not a lookup. Files: `src/import/map.ts` ### Done when - [ ] Derived positions match a statement fixture exactly - [ ] A repeated import adds nothing - [ ] A multi-currency transaction shows its working - [ ] No position can be edited directly ### Watch out - Letting someone type today's holdings and calling it a portfolio leaves no cost basis, no history and nothing to check against anything, so every gain figure is a guess and a double import goes unnoticed for a year. Store transactions, derive positions, and put a reconciliation screen in front of the dashboard so a mismatch is visible on the day it appears rather than at the next tax year. ## Phase 2 · No valuation without its source and its age Every figure on the screen can be traced to a price, a source and a time. ### Steps 1. Stamp source and as-of on every price row A price without provenance is a rumour with two decimal places. Files: `src/prices/store.ts` 2. Put the oldest price age beside the headline It is the one number that tells you whether to trust the others. Files: `src/app/page.tsx` 3. Keep the whole thing working with the feed off Manual prices are the floor, not the fallback. Files: `src/prices/manual.ts`, `tests/no-feed.test.ts` ### Done when - [ ] Every valuation shows source and as-of - [ ] Stale prices are marked and the oldest age is on the headline - [ ] The app values everything with the feed disabled - [ ] A failed fetch never extends an existing price ### Watch out - Falling back to the last known price when a feed fails makes a portfolio valued on Friday look as though it was valued this morning, and the first time anybody notices is when a figure somebody acted on turns out to be a fortnight old. Stamp every price with its source and time, mark stale ones visibly, and put the age of the oldest price next to the headline total rather than behind a hover. ## Phase 3 · Projections made only of assumptions somebody typed The chart cannot be drawn until every input has been supplied and attributed. ### Steps 1. Ship no default growth rate An unowned number becomes everyone's number. Files: `src/projection/inputs.ts` 2. Render a band, never a line A single line is a forecast and you do not have one. Files: `src/projection/chart.tsx` 3. Attribute every assumption and carry them on the chart So the screenshot arrives with its own footnotes. Files: `src/projection/assumptions.ts`, `tests/projection.test.ts` ### Done when - [ ] The projection refuses to render with a missing input - [ ] No path draws a single-line projection - [ ] Assumptions record who set them and when - [ ] Exports carry the assumptions ### Watch out - Shipping a default growth rate means the chart somebody screenshots and shows their partner was produced by a number your application chose, and every conversation afterwards is about a figure nobody in the room decided. Ship none: leave the projection page empty until the assumptions are entered, attribute each one, and carry them on the face of the chart rather than in a panel underneath. ## Phase 4 · A planning page that expires Every tax figure has a source, a checked date and a review date, and the page stops rather than ages. ### Steps 1. Keep every figure in a dated rules file with a review date The figure and its expiry are one fact. Files: `data/rules/uk-2026.json`, `src/rules.ts` 2. Let the page refuse rather than render an old number Showing nothing is honest; showing last year's threshold is not. Files: `src/app/planning/page.tsx`, `tests/expiry.test.ts` 3. Bind the label to the figure in one component A disclaimer in the footer is a disclaimer nobody reads. Files: `src/components/TaxFigure.tsx` ### Done when - [ ] Every rule has source, checked date and review date - [ ] A passed review date hides the figure and names the source - [ ] Updates add rows rather than editing them - [ ] The label cannot render without the figure ### Watch out - Hard-coding a threshold that has been frozen for years feels safe precisely because it has not moved, and then a Finance Act extends the freeze again and your figure is right for the wrong reason until the year it silently is not. Put every figure in a dated file with its source, let the page refuse once the review date passes, and make updating the file a new row rather than an edit so an old estimate can still be explained. ## Not in this build - Any suggestion about a particular investment. - Segment-based suggestions. - Broker and bank connections. - A definitive tax calculation. ## After v1, if you want it - Corporate actions handled as transactions, since a share split silently breaks every derived position - A second jurisdiction's rules file, which is the real test of whether the rules abstraction holds - A dated snapshot of the whole household position, so a projection made last year can be scored against what happened ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. The ledger, the prices, the assumptions and the reviews. Back it up properly. DATABASE_PATH=./data/portfolio.db # Required. Dated tax rules files. Updates add rows; nothing is edited in place. RULES_PATH=./data/rules # Required. Past this a price is marked stale and its age appears on the headline. PRICE_STALE_HOURS=48 # Optional · secret. Optional. With it absent the application values everything from manual prices. PRICE_FEED_KEY=pk_...redacted # Optional · secret. The review. The only outbound call, and the only thing with a spend ceiling. MODEL_API_KEY=sk-...redacted # Required. Reviews stop at this figure. The tracker carries on without them. MODEL_MONTHLY_CEILING=2.00 # Required · secret. Encrypts the export. Losing it loses the export, not the database. EXPORT_PASSPHRASE=a-long-passphrase-redacted # Required. Everything is converted into this for totals, with the rate and its date stored. BASE_CURRENCY=GBP
You are building a production product version of Portfolio Coach. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Portfolio Coach · product brief ## Problem A private dashboard for holdings, family members, projections, and CSV imports is a credible weekend build. Reliable live pricing for UK funds, a ledger-correct Vanguard importer, household data boundaries, maintained tax rules, and AI advice grounded in every account turn the complete product into a multi-day project. ## Product outcome A household tracker whose positions come from a ledger you can reconcile, whose every figure names its source and its age, whose projections are made only of assumptions somebody typed, and whose planning page stops rather than quietly showing last year's threshold. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node 22 - OpenAI-compatible API key for AI reviews - optional market-data API key - local SQLite database ## Explicit non-goals for v1 - Any suggestion about a particular investment. - Segment-based suggestions. - Broker and bank connections. - A definitive tax calculation. - reliable live pricing and UK fund symbol mapping - ledger-backed Vanguard history import - secure cloud accounts, family sharing, and support access - maintained UK tax and estate-planning assumptions - hosted AI reviews with current web context ## Success criteria - Derived positions are confirmed to reconcile against a real statement - No valuation is confirmed to render without its source and age - The projection is confirmed to refuse without a complete set of assumptions - A passed review date is confirmed to hide the planning figure - Adversarial prompts are confirmed to produce no instrument-level imperative ===== BRIEF.md ===== # Build brief · Portfolio Coach The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a private household portfolio tracker that describes what you hold and refuses to suggest what to do about it to replace Portfolio Coach. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero This one has a regulator, and the boundary is not where most builders assume it is. In the United Kingdom, article 53 of the Regulated Activities Order makes advising a specified activity where the advice is given to a person 'in his capacity as an investor or potential investor' and is 'advice on the merits' of buying, selling, holding or exchanging 'a particular investment'. Two elements: the merits, and a particular investment. The tempting move is to conclude that anything impersonal is therefore safe. That argument has just been narrowed from the other direction. A new article 55A, inserted with effect from 2026, created a regulated activity called providing targeted support, and the regulator's own rules describe it as delivering 'a ready-made suggestion by reference to the client's alignment with a pre-defined consumer segment', at scale, on limited information. Firms that wanted to make segment-level suggestions needed a permission created for the purpose. Checked at legislation.gov.uk and the regulator's policy statement on 15 September 2026. So the line this build draws is not about personalisation. It is: this tool describes, and does not suggest. It reports what the household holds, what it is worth, how concentrated it is, and what a projection does under assumptions the user typed in themselves. It never names an instrument to buy, sell, hold or switch, and there is no table it could store such a thing in. Second, the numbers go stale by being extended rather than by changing. The annual ISA subscription limit is 20,000 pounds for 2026 to 2027, and the inheritance tax nil rate band is 325,000 pounds with a residence band of 175,000 and a taper starting at 2 million, held there by successive Finance Acts and currently to 2030 to 2031. Checked on gov.uk on 15 September 2026. Every such figure lives in a dated rules file with a review date, and the planning page shows nothing rather than an old number once that date has passed. ### Stack (fixed, do not substitute) - Runtime: Next.js with TypeScript and SQLite, bound to localhost · a household's entire financial position should not need a server somebody else operates - Ledger: Transactions are the record; positions are derived and rebuildable · a position you typed in cannot be reconciled against a statement - Money: Integer minor units with a currency code, and every rate stored with its date · a portfolio in four currencies compared in floating point is wrong by an amount nobody can find - Rules: A dated tax rules file with a source and a review date, checked at render time · frozen thresholds get extended, and an extended threshold is a silently wrong number - Model: One call that describes and asks questions, with a refusal check on the output · the line between describing a portfolio and advising on it is a regulated one ### Data model (create this before Phase 1) - people(id, name, relationship) and accounts(id, person_id, wrapper, provider, currency, opened_on) - transactions(id, account_id, at, kind, instrument_id, units, price_minor, price_currency, fee_minor, fx_rate, fx_rate_date, source_row) · the record everything else is derived from - instruments(id, symbol, name, currency, kind, identifier_kind, identifier) · the identifier kind matters because the same fund answers to several - prices(instrument_id, as_of, price_minor, currency, source) · a price with no source and no date is not shown - positions_cache(account_id, instrument_id, as_of, units, book_cost_minor) · derived, rebuildable, never authoritative - assumptions(id, name, value, unit, set_by, set_at) · every projection input, attributed to the person who typed it - tax_rules(id, jurisdiction, name, value_minor, tax_year, source_url, checked_on, review_by) · the planning page refuses once the review date has passed - reviews(id, at, model_id, prompt_version, input_summary_json, output_text, refusals_json) · what was sent and what came back, because 'the app told me to' needs an answer - There is no table for a recommendation, a target allocation or a suggested trade. There is nowhere to put one, and that absence is the design. ### Phase 1 · A ledger you can reconcile, not a list of holdings Build: Model transactions with units, price, fee, and a conversion rate with its date. Derive positions and book cost. Import CSV with saved column mappings and a stable row hash. Add a reconciliation screen that compares derived units and value against figures typed from a statement. Trap: Letting someone type today's holdings and calling it a portfolio leaves no cost basis, no history and nothing to check against anything, so every gain figure is a guess and a double import goes unnoticed for a year. Store transactions, derive positions, and put a reconciliation screen in front of the dashboard so a mismatch is visible on the day it appears rather than at the next tax year. Done when: Derived positions match a reconciliation figure for a fixture account to the penny, tested. Importing the same file twice adds nothing, tested against the row hash. A transaction with a fee in one currency and a price in another produces a book cost that can be checked by hand, with the working shown. No position is directly editable anywhere in the interface, verified by review. Do not build yet: No pricing, projections, tax rules or model review yet. ### Phase 2 · No valuation without its source and its age Build: Support manual prices first and an optional feed second. Store source and as-of on every price. Mark anything past a configured age as stale and display the oldest price's age beside the household total. Keep the whole application working with the feed switched off. Trap: Falling back to the last known price when a feed fails makes a portfolio valued on Friday look as though it was valued this morning, and the first time anybody notices is when a figure somebody acted on turns out to be a fortnight old. Stamp every price with its source and time, mark stale ones visibly, and put the age of the oldest price next to the headline total rather than behind a hover. Done when: No valuation renders anywhere without its source and as-of time, verified by review and a test over the rendered pages. A stale price is visibly marked and the headline total carries the age of the oldest price behind it. With the feed disabled the application values everything from manual prices and says so. A price fetch failure never overwrites or extends an existing price row. Do not build yet: No projections, planning page or model review yet. ### Phase 3 · Projections made only of assumptions somebody typed Build: Make every projection input explicit: contributions, growth, inflation, withdrawals, retirement age, planned gifts. Attribute each to the person who entered it and the time. Render at least three scenarios as a band, never a single line. Ship no default growth rate at all. Trap: Shipping a default growth rate means the chart somebody screenshots and shows their partner was produced by a number your application chose, and every conversation afterwards is about a figure nobody in the room decided. Ship none: leave the projection page empty until the assumptions are entered, attribute each one, and carry them on the face of the chart rather than in a panel underneath. Done when: The projection refuses to render until every assumption has a value, tested by omitting each in turn. The output is a band from at least three scenarios and there is no code path that draws a single line, verified by review. Changing an assumption records who changed it and when. The assumptions appear on any exported image or file, tested by inspecting an export. Do not build yet: No tax rules, model review or household access boundaries yet. ### Phase 4 · A planning page that expires Build: Put every threshold and allowance in a dated rules file with a jurisdiction, a tax year, a source link, a checked date and a review date. Render the planning page from it. Once the review date has passed, show no figure at all and name what to check and where. Label the page informational in the same component that renders the number, not in a footer. Trap: Hard-coding a threshold that has been frozen for years feels safe precisely because it has not moved, and then a Finance Act extends the freeze again and your figure is right for the wrong reason until the year it silently is not. Put every figure in a dated file with its source, let the page refuse once the review date passes, and make updating the file a new row rather than an edit so an old estimate can still be explained. Done when: Every rule row has a source link, a checked date and a review date, enforced by a not-null constraint and a test. With the clock advanced past a review date, the planning page shows no figure and names the source to check, tested. Updating a figure adds a row and leaves the old one, tested. The informational label renders in the same component as the figure and cannot be displayed without it. Do not build yet: No model review or household access boundaries yet. ### Phase 5 · The line the software will not cross Build: Send only a computed summary to the model: aggregates, weights and the assumptions, with no names, account numbers or transaction rows. Constrain the prompt to describing and asking. Check the output for instrument-level imperatives before it renders and refuse rather than edit. Store every review with its prompt version and refusals. Add per-person access so a household member sees only the accounts they are linked to, and an encrypted export of everything. Trap: An open-ended 'review my portfolio' prompt returns a suggestion about a specific holding roughly every other time, because that is what a portfolio review looks like in the material the model learned from, and once it is on the screen the household has received something that reads exactly like advice from a tool sitting in their own house. Keep an adversarial prompt set in the suite, check the output before it renders, and refuse the whole response rather than deleting the offending sentence. Done when: A stored set of adversarial prompts produces no output naming a buy, sell, hold or switch of a specific instrument, run as a test. The payload sent to the model contains no names, account identifiers or transaction rows, asserted against a snapshot. Every review stores its prompt version, the summary sent and any refusals. A household member signing in sees only accounts they are linked to, tested with two people, and the encrypted export restores into an empty database. Do not build yet: Nothing after this. ### Out of scope (and why) - Any suggestion about a particular investment, which is the element article 53 turns on and the reason the schema has nowhere to store one. - Segment-based suggestions of the 'people like you usually' shape, which is precisely what the 2026 targeted support regime made a permissioned activity rather than a clever workaround. - Broker and bank connections, which mean holding credentials to other people's money and are a different category of responsibility from a tracker. - A definitive tax calculation. The planning page is an indicative figure from a dated file, and it stops showing anything once that file is out of date. ### README must contain - The two elements of article 53, quoted, with the source and the date checked, and the statement that this tool describes rather than suggests - That segment-level suggestions became a separately permissioned activity in 2026, so 'it is generic, therefore it is fine' is not the safe harbour it looks like - That every tax figure carries a source, a checked date and a review date, and that the page shows nothing once that date has passed - That positions are derived from transactions and that the reconciliation screen is how you check them - That the model receives aggregates only, and what the adversarial prompt test checks for ===== ARCHITECTURE.md ===== # Architecture · Portfolio Coach ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js with TypeScript and SQLite, bound to localhost | a household's entire financial position should not need a server somebody else operates | | Ledger | Transactions are the record; positions are derived and rebuildable | a position you typed in cannot be reconciled against a statement | | Money | Integer minor units with a currency code, and every rate stored with its date | a portfolio in four currencies compared in floating point is wrong by an amount nobody can find | | Rules | A dated tax rules file with a source and a review date, checked at render time | frozen thresholds get extended, and an extended threshold is a silently wrong number | | Model | One call that describes and asks questions, with a refusal check on the output | the line between describing a portfolio and advising on it is a regulated one | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Ledger | Transactions, derivation and reconciliation. | Any store, provided positions stay derived and the reconciliation screen exists. | | Prices | Manual and fed prices with source and age. | Any feed, provided a failure never extends an existing price. | | Projection | Assumptions, attribution and the band. | Any model, provided it ships no default and draws no single line. | | Rules | Dated figures with review dates. | Any format, provided the page refuses once the review date passes. | | Review | The payload, the prompt and the output check. | Any provider. Never swappable for an unchecked output. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · The ledger, the prices, the assumptions and the reviews. Back it up properly. - `RULES_PATH` · required · Dated tax rules files. Updates add rows; nothing is edited in place. - `PRICE_STALE_HOURS` · required · Past this a price is marked stale and its age appears on the headline. - `PRICE_FEED_KEY` · optional, secret · Optional. With it absent the application values everything from manual prices. - `MODEL_API_KEY` · optional, secret · The review. The only outbound call, and the only thing with a spend ceiling. - `MODEL_MONTHLY_CEILING` · required · Reviews stop at this figure. The tracker carries on without them. - `EXPORT_PASSPHRASE` · required, secret · Encrypts the export. Losing it loses the export, not the database. - `BASE_CURRENCY` · required · Everything is converted into this for totals, with the rate and its date stored. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Portfolio Coach product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js with TypeScript and SQLite, bound to localhost, Transactions are the record; positions are derived and rebuildable, Integer minor units with a currency code, and every rate stored with its date, A dated tax rules file with a source and a review date, checked at render time, One call that describes and asks questions, with a refusal check on the output. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Letting someone type today's holdings and calling it a portfolio leaves no cost basis, no history and nothing to check against anything, so every gain figure is a guess and a double import goes unnoticed for a year. Store transactions, derive positions, and put a reconciliation screen in front of the dashboard so a mismatch is visible on the day it appears rather than at the next tax year. - Falling back to the last known price when a feed fails makes a portfolio valued on Friday look as though it was valued this morning, and the first time anybody notices is when a figure somebody acted on turns out to be a fortnight old. Stamp every price with its source and time, mark stale ones visibly, and put the age of the oldest price next to the headline total rather than behind a hover. - Shipping a default growth rate means the chart somebody screenshots and shows their partner was produced by a number your application chose, and every conversation afterwards is about a figure nobody in the room decided. Ship none: leave the projection page empty until the assumptions are entered, attribute each one, and carry them on the face of the chart rather than in a panel underneath. - Hard-coding a threshold that has been frozen for years feels safe precisely because it has not moved, and then a Finance Act extends the freeze again and your figure is right for the wrong reason until the year it silently is not. Put every figure in a dated file with its source, let the page refuse once the review date passes, and make updating the file a new row rather than an edit so an old estimate can still be explained. - An open-ended 'review my portfolio' prompt returns a suggestion about a specific holding roughly every other time, because that is what a portfolio review looks like in the material the model learned from, and once it is on the screen the household has received something that reads exactly like advice from a tool sitting in their own house. Keep an adversarial prompt set in the suite, check the output before it renders, and refuse the whole response rather than deleting the offending sentence. ===== MILESTONES.md ===== # Delivery milestones · Portfolio Coach Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A ledger you can reconcile, not a list of holdings Positions are derived from transactions and can be checked against a real statement. ### Steps 1. Make transactions the only writable record Everything else is a view and should be deletable without loss. Files: `src/db/schema.sql`, `src/positions.ts` 2. Put reconciliation in front of the dashboard A pretty number nobody has checked is worse than no number. Files: `src/app/reconcile/page.tsx`, `tests/reconcile.test.ts` 3. Store the conversion rate with its date on the transaction The rate on the day is part of the transaction, not a lookup. Files: `src/import/map.ts` ### Done when - [ ] Derived positions match a statement fixture exactly - [ ] A repeated import adds nothing - [ ] A multi-currency transaction shows its working - [ ] No position can be edited directly ### Watch out - Letting someone type today's holdings and calling it a portfolio leaves no cost basis, no history and nothing to check against anything, so every gain figure is a guess and a double import goes unnoticed for a year. Store transactions, derive positions, and put a reconciliation screen in front of the dashboard so a mismatch is visible on the day it appears rather than at the next tax year. ## M2 · No valuation without its source and its age Every figure on the screen can be traced to a price, a source and a time. ### Steps 1. Stamp source and as-of on every price row A price without provenance is a rumour with two decimal places. Files: `src/prices/store.ts` 2. Put the oldest price age beside the headline It is the one number that tells you whether to trust the others. Files: `src/app/page.tsx` 3. Keep the whole thing working with the feed off Manual prices are the floor, not the fallback. Files: `src/prices/manual.ts`, `tests/no-feed.test.ts` ### Done when - [ ] Every valuation shows source and as-of - [ ] Stale prices are marked and the oldest age is on the headline - [ ] The app values everything with the feed disabled - [ ] A failed fetch never extends an existing price ### Watch out - Falling back to the last known price when a feed fails makes a portfolio valued on Friday look as though it was valued this morning, and the first time anybody notices is when a figure somebody acted on turns out to be a fortnight old. Stamp every price with its source and time, mark stale ones visibly, and put the age of the oldest price next to the headline total rather than behind a hover. ## M3 · Projections made only of assumptions somebody typed The chart cannot be drawn until every input has been supplied and attributed. ### Steps 1. Ship no default growth rate An unowned number becomes everyone's number. Files: `src/projection/inputs.ts` 2. Render a band, never a line A single line is a forecast and you do not have one. Files: `src/projection/chart.tsx` 3. Attribute every assumption and carry them on the chart So the screenshot arrives with its own footnotes. Files: `src/projection/assumptions.ts`, `tests/projection.test.ts` ### Done when - [ ] The projection refuses to render with a missing input - [ ] No path draws a single-line projection - [ ] Assumptions record who set them and when - [ ] Exports carry the assumptions ### Watch out - Shipping a default growth rate means the chart somebody screenshots and shows their partner was produced by a number your application chose, and every conversation afterwards is about a figure nobody in the room decided. Ship none: leave the projection page empty until the assumptions are entered, attribute each one, and carry them on the face of the chart rather than in a panel underneath. ## M4 · A planning page that expires Every tax figure has a source, a checked date and a review date, and the page stops rather than ages. ### Steps 1. Keep every figure in a dated rules file with a review date The figure and its expiry are one fact. Files: `data/rules/uk-2026.json`, `src/rules.ts` 2. Let the page refuse rather than render an old number Showing nothing is honest; showing last year's threshold is not. Files: `src/app/planning/page.tsx`, `tests/expiry.test.ts` 3. Bind the label to the figure in one component A disclaimer in the footer is a disclaimer nobody reads. Files: `src/components/TaxFigure.tsx` ### Done when - [ ] Every rule has source, checked date and review date - [ ] A passed review date hides the figure and names the source - [ ] Updates add rows rather than editing them - [ ] The label cannot render without the figure ### Watch out - Hard-coding a threshold that has been frozen for years feels safe precisely because it has not moved, and then a Finance Act extends the freeze again and your figure is right for the wrong reason until the year it silently is not. Put every figure in a dated file with its source, let the page refuse once the review date passes, and make updating the file a new row rather than an edit so an old estimate can still be explained. ## M5 · The line the software will not cross (production only) The model describes and questions, the household's data stays inside, and everything exports. ### Steps 1. Send aggregates, never the ledger Snapshot the payload in a test so a later change is visible. Files: `src/review/payload.ts`, `tests/payload.test.ts` 2. Check the output and refuse the whole response Editing out a sentence leaves you with an edited recommendation. Files: `src/review/guard.ts`, `tests/adversarial.test.ts` 3. Scope each person to their own accounts A household tracker is several people's private information. Files: `src/auth/scope.ts`, `src/export.ts` ### Done when - [ ] Adversarial prompts produce no instrument-level imperative - [ ] The model payload carries no names or ledger rows - [ ] Reviews store prompt version, summary and refusals - [ ] Per-person access and encrypted export both hold ### Watch out - An open-ended 'review my portfolio' prompt returns a suggestion about a specific holding roughly every other time, because that is what a portfolio review looks like in the material the model learned from, and once it is on the screen the household has received something that reads exactly like advice from a tool sitting in their own house. Keep an adversarial prompt set in the suite, check the output before it renders, and refuse the whole response rather than deleting the offending sentence. ===== OPERATIONS.md ===== # Operations · Portfolio Coach ## Backup The database and the rules directory together, nightly and offsite, because a window into an old valuation can only be explained by the rules version that produced it. The encrypted export is the format that survives a schema change. ## Restore Restore both, rebuild the positions cache from transactions and confirm the reconciliation figures still match. A restore that recomputes a planning figure under today's rules rather than the stored ones has rewritten what the household was shown. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Age of the oldest price, rules rows within sixty days of their review date, reconciliation mismatches, and model spend against the ceiling. The second is the one that catches a quietly wrong planning page before somebody acts on it. ## Incident checklist The realistic incident is a household decision taken on a number that was stale or on a sentence that read like advice. The stored review with its prompt version, the price provenance and the rules row with its checked date are what let you say exactly what was shown and on what basis. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Derived positions are confirmed to reconcile against a real statement - [ ] No valuation is confirmed to render without its source and age - [ ] The projection is confirmed to refuse without a complete set of assumptions - [ ] A passed review date is confirmed to hide the planning figure - [ ] Adversarial prompts are confirmed to produce no instrument-level imperative ## Launch constraint Do not market omitted Portfolio Coach capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. The ledger, the prices, the assumptions and the reviews. Back it up properly. DATABASE_PATH=./data/portfolio.db # Required. Dated tax rules files. Updates add rows; nothing is edited in place. RULES_PATH=./data/rules # Required. Past this a price is marked stale and its age appears on the headline. PRICE_STALE_HOURS=48 # Optional · secret. Optional. With it absent the application values everything from manual prices. PRICE_FEED_KEY=pk_...redacted # Optional · secret. The review. The only outbound call, and the only thing with a spend ceiling. MODEL_API_KEY=sk-...redacted # Required. Reviews stop at this figure. The tracker carries on without them. MODEL_MONTHLY_CEILING=2.00 # Required · secret. Encrypts the export. Losing it loses the export, not the database. EXPORT_PASSPHRASE=a-long-passphrase-redacted # Required. Everything is converted into this for totals, with the rate and its date stored. BASE_CURRENCY=GBP
# Portfolio Coach · indie build A household tracker whose hard problems are deriving positions from a transaction ledger you can reconcile, never displaying a valuation without its source and age, and keeping a model review on the describing side of a regulated line. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js with TypeScript and SQLite, bound to localhost | a household's entire financial position should not need a server somebody else operates | | Ledger | Transactions are the record; positions are derived and rebuildable | a position you typed in cannot be reconciled against a statement | | Money | Integer minor units with a currency code, and every rate stored with its date | a portfolio in four currencies compared in floating point is wrong by an amount nobody can find | | Rules | A dated tax rules file with a source and a review date, checked at render time | frozen thresholds get extended, and an extended threshold is a silently wrong number | | Model | One call that describes and asks questions, with a refusal check on the output | the line between describing a portfolio and advising on it is a regulated one | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the tracker locally. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **Real statements and transaction exports for at least two accounts** · free - Why: Reconciliation is the only thing that proves the ledger is right, and it needs a figure produced by somebody other than your own code. - Get it: Download transaction history and a valuation statement from the same date for two accounts. - Verify: Derived positions match the statement to the penny - [ ] **A dated tax rules file you compiled yourself** · free - Why: Every figure on the planning page comes from it, and a figure with no source and no review date is a number that will be quietly wrong. - Get it: Read the current published thresholds on the revenue authority's own pages, record the value, the tax year, the source link and the date you checked, and set a review date. - Verify: Every row has a source, a checked date and a review date, and a test asserts it - [ ] **Written description of what the software will not say** · free - Why: A tracker with a model attached drifts towards advice one helpful feature at a time, and the boundary has to exist before the first review is generated. - Get it: Write down that no output names an instrument to buy, sell, hold or switch, that no segment-based suggestion is produced, and what the output check refuses. - Verify: The adversarial prompt test enforces the same list the README states - [ ] **An API key for a language model** (optional) · pennies per review - Why: The review is the only part that leaves the machine, and it should be the only part with a spend limit and a provider-side ceiling. - Get it: Create a key with your preferred provider and set a spend limit in their console. - Verify: A review runs and the payload snapshot test passes ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Any suggestion about a particular investment. - Segment-based suggestions. - Broker and bank connections. - A definitive tax calculation. - reliable live pricing and UK fund symbol mapping - ledger-backed Vanguard history import - secure cloud accounts, family sharing, and support access - maintained UK tax and estate-planning assumptions - hosted AI reviews with current web context If one of those is essential to you, that is the reason to keep paying for Portfolio Coach, and the README should say so rather than pretend.
# Build brief · Portfolio Coach The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a private household portfolio tracker that describes what you hold and refuses to suggest what to do about it to replace Portfolio Coach. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero This one has a regulator, and the boundary is not where most builders assume it is. In the United Kingdom, article 53 of the Regulated Activities Order makes advising a specified activity where the advice is given to a person 'in his capacity as an investor or potential investor' and is 'advice on the merits' of buying, selling, holding or exchanging 'a particular investment'. Two elements: the merits, and a particular investment. The tempting move is to conclude that anything impersonal is therefore safe. That argument has just been narrowed from the other direction. A new article 55A, inserted with effect from 2026, created a regulated activity called providing targeted support, and the regulator's own rules describe it as delivering 'a ready-made suggestion by reference to the client's alignment with a pre-defined consumer segment', at scale, on limited information. Firms that wanted to make segment-level suggestions needed a permission created for the purpose. Checked at legislation.gov.uk and the regulator's policy statement on 15 September 2026. So the line this build draws is not about personalisation. It is: this tool describes, and does not suggest. It reports what the household holds, what it is worth, how concentrated it is, and what a projection does under assumptions the user typed in themselves. It never names an instrument to buy, sell, hold or switch, and there is no table it could store such a thing in. Second, the numbers go stale by being extended rather than by changing. The annual ISA subscription limit is 20,000 pounds for 2026 to 2027, and the inheritance tax nil rate band is 325,000 pounds with a residence band of 175,000 and a taper starting at 2 million, held there by successive Finance Acts and currently to 2030 to 2031. Checked on gov.uk on 15 September 2026. Every such figure lives in a dated rules file with a review date, and the planning page shows nothing rather than an old number once that date has passed. ### Stack (fixed, do not substitute) - Runtime: Next.js with TypeScript and SQLite, bound to localhost · a household's entire financial position should not need a server somebody else operates - Ledger: Transactions are the record; positions are derived and rebuildable · a position you typed in cannot be reconciled against a statement - Money: Integer minor units with a currency code, and every rate stored with its date · a portfolio in four currencies compared in floating point is wrong by an amount nobody can find - Rules: A dated tax rules file with a source and a review date, checked at render time · frozen thresholds get extended, and an extended threshold is a silently wrong number - Model: One call that describes and asks questions, with a refusal check on the output · the line between describing a portfolio and advising on it is a regulated one ### Data model (create this before Phase 1) - people(id, name, relationship) and accounts(id, person_id, wrapper, provider, currency, opened_on) - transactions(id, account_id, at, kind, instrument_id, units, price_minor, price_currency, fee_minor, fx_rate, fx_rate_date, source_row) · the record everything else is derived from - instruments(id, symbol, name, currency, kind, identifier_kind, identifier) · the identifier kind matters because the same fund answers to several - prices(instrument_id, as_of, price_minor, currency, source) · a price with no source and no date is not shown - positions_cache(account_id, instrument_id, as_of, units, book_cost_minor) · derived, rebuildable, never authoritative - assumptions(id, name, value, unit, set_by, set_at) · every projection input, attributed to the person who typed it - tax_rules(id, jurisdiction, name, value_minor, tax_year, source_url, checked_on, review_by) · the planning page refuses once the review date has passed - reviews(id, at, model_id, prompt_version, input_summary_json, output_text, refusals_json) · what was sent and what came back, because 'the app told me to' needs an answer - There is no table for a recommendation, a target allocation or a suggested trade. There is nowhere to put one, and that absence is the design. ### Phase 1 · A ledger you can reconcile, not a list of holdings Build: Model transactions with units, price, fee, and a conversion rate with its date. Derive positions and book cost. Import CSV with saved column mappings and a stable row hash. Add a reconciliation screen that compares derived units and value against figures typed from a statement. Trap: Letting someone type today's holdings and calling it a portfolio leaves no cost basis, no history and nothing to check against anything, so every gain figure is a guess and a double import goes unnoticed for a year. Store transactions, derive positions, and put a reconciliation screen in front of the dashboard so a mismatch is visible on the day it appears rather than at the next tax year. Done when: Derived positions match a reconciliation figure for a fixture account to the penny, tested. Importing the same file twice adds nothing, tested against the row hash. A transaction with a fee in one currency and a price in another produces a book cost that can be checked by hand, with the working shown. No position is directly editable anywhere in the interface, verified by review. Do not build yet: No pricing, projections, tax rules or model review yet. ### Phase 2 · No valuation without its source and its age Build: Support manual prices first and an optional feed second. Store source and as-of on every price. Mark anything past a configured age as stale and display the oldest price's age beside the household total. Keep the whole application working with the feed switched off. Trap: Falling back to the last known price when a feed fails makes a portfolio valued on Friday look as though it was valued this morning, and the first time anybody notices is when a figure somebody acted on turns out to be a fortnight old. Stamp every price with its source and time, mark stale ones visibly, and put the age of the oldest price next to the headline total rather than behind a hover. Done when: No valuation renders anywhere without its source and as-of time, verified by review and a test over the rendered pages. A stale price is visibly marked and the headline total carries the age of the oldest price behind it. With the feed disabled the application values everything from manual prices and says so. A price fetch failure never overwrites or extends an existing price row. Do not build yet: No projections, planning page or model review yet. ### Phase 3 · Projections made only of assumptions somebody typed Build: Make every projection input explicit: contributions, growth, inflation, withdrawals, retirement age, planned gifts. Attribute each to the person who entered it and the time. Render at least three scenarios as a band, never a single line. Ship no default growth rate at all. Trap: Shipping a default growth rate means the chart somebody screenshots and shows their partner was produced by a number your application chose, and every conversation afterwards is about a figure nobody in the room decided. Ship none: leave the projection page empty until the assumptions are entered, attribute each one, and carry them on the face of the chart rather than in a panel underneath. Done when: The projection refuses to render until every assumption has a value, tested by omitting each in turn. The output is a band from at least three scenarios and there is no code path that draws a single line, verified by review. Changing an assumption records who changed it and when. The assumptions appear on any exported image or file, tested by inspecting an export. Do not build yet: No tax rules, model review or household access boundaries yet. ### Phase 4 · A planning page that expires Build: Put every threshold and allowance in a dated rules file with a jurisdiction, a tax year, a source link, a checked date and a review date. Render the planning page from it. Once the review date has passed, show no figure at all and name what to check and where. Label the page informational in the same component that renders the number, not in a footer. Trap: Hard-coding a threshold that has been frozen for years feels safe precisely because it has not moved, and then a Finance Act extends the freeze again and your figure is right for the wrong reason until the year it silently is not. Put every figure in a dated file with its source, let the page refuse once the review date passes, and make updating the file a new row rather than an edit so an old estimate can still be explained. Done when: Every rule row has a source link, a checked date and a review date, enforced by a not-null constraint and a test. With the clock advanced past a review date, the planning page shows no figure and names the source to check, tested. Updating a figure adds a row and leaves the old one, tested. The informational label renders in the same component as the figure and cannot be displayed without it. Do not build yet: No model review or household access boundaries yet. ### Phase 5 · The line the software will not cross Build: Send only a computed summary to the model: aggregates, weights and the assumptions, with no names, account numbers or transaction rows. Constrain the prompt to describing and asking. Check the output for instrument-level imperatives before it renders and refuse rather than edit. Store every review with its prompt version and refusals. Add per-person access so a household member sees only the accounts they are linked to, and an encrypted export of everything. Trap: An open-ended 'review my portfolio' prompt returns a suggestion about a specific holding roughly every other time, because that is what a portfolio review looks like in the material the model learned from, and once it is on the screen the household has received something that reads exactly like advice from a tool sitting in their own house. Keep an adversarial prompt set in the suite, check the output before it renders, and refuse the whole response rather than deleting the offending sentence. Done when: A stored set of adversarial prompts produces no output naming a buy, sell, hold or switch of a specific instrument, run as a test. The payload sent to the model contains no names, account identifiers or transaction rows, asserted against a snapshot. Every review stores its prompt version, the summary sent and any refusals. A household member signing in sees only accounts they are linked to, tested with two people, and the encrypted export restores into an empty database. Do not build yet: Nothing after this. ### Out of scope (and why) - Any suggestion about a particular investment, which is the element article 53 turns on and the reason the schema has nowhere to store one. - Segment-based suggestions of the 'people like you usually' shape, which is precisely what the 2026 targeted support regime made a permissioned activity rather than a clever workaround. - Broker and bank connections, which mean holding credentials to other people's money and are a different category of responsibility from a tracker. - A definitive tax calculation. The planning page is an indicative figure from a dated file, and it stops showing anything once that file is out of date. ### README must contain - The two elements of article 53, quoted, with the source and the date checked, and the statement that this tool describes rather than suggests - That segment-level suggestions became a separately permissioned activity in 2026, so 'it is generic, therefore it is fine' is not the safe harbour it looks like - That every tax figure carries a source, a checked date and a review date, and that the page shows nothing once that date has passed - That positions are derived from transactions and that the reconciliation screen is how you check them - That the model receives aggregates only, and what the adversarial prompt test checks for
# Agent instructions · Portfolio Coach indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js with TypeScript and SQLite, bound to localhost, Transactions are the record; positions are derived and rebuildable, Integer minor units with a currency code, and every rate stored with its date, A dated tax rules file with a source and a review date, checked at render time, One call that describes and asks questions, with a refusal check on the output. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Letting someone type today's holdings and calling it a portfolio leaves no cost basis, no history and nothing to check against anything, so every gain figure is a guess and a double import goes unnoticed for a year. Store transactions, derive positions, and put a reconciliation screen in front of the dashboard so a mismatch is visible on the day it appears rather than at the next tax year. - Falling back to the last known price when a feed fails makes a portfolio valued on Friday look as though it was valued this morning, and the first time anybody notices is when a figure somebody acted on turns out to be a fortnight old. Stamp every price with its source and time, mark stale ones visibly, and put the age of the oldest price next to the headline total rather than behind a hover. - Shipping a default growth rate means the chart somebody screenshots and shows their partner was produced by a number your application chose, and every conversation afterwards is about a figure nobody in the room decided. Ship none: leave the projection page empty until the assumptions are entered, attribute each one, and carry them on the face of the chart rather than in a panel underneath. - Hard-coding a threshold that has been frozen for years feels safe precisely because it has not moved, and then a Finance Act extends the freeze again and your figure is right for the wrong reason until the year it silently is not. Put every figure in a dated file with its source, let the page refuse once the review date passes, and make updating the file a new row rather than an edit so an old estimate can still be explained.
# Build plan · Portfolio Coach A household tracker whose hard problems are deriving positions from a transaction ledger you can reconcile, never displaying a valuation without its source and age, and keeping a model review on the describing side of a regulated line. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A ledger you can reconcile, not a list of holdings Positions are derived from transactions and can be checked against a real statement. ### Steps 1. Make transactions the only writable record Everything else is a view and should be deletable without loss. Files: `src/db/schema.sql`, `src/positions.ts` 2. Put reconciliation in front of the dashboard A pretty number nobody has checked is worse than no number. Files: `src/app/reconcile/page.tsx`, `tests/reconcile.test.ts` 3. Store the conversion rate with its date on the transaction The rate on the day is part of the transaction, not a lookup. Files: `src/import/map.ts` ### Done when - [ ] Derived positions match a statement fixture exactly - [ ] A repeated import adds nothing - [ ] A multi-currency transaction shows its working - [ ] No position can be edited directly ### Watch out - Letting someone type today's holdings and calling it a portfolio leaves no cost basis, no history and nothing to check against anything, so every gain figure is a guess and a double import goes unnoticed for a year. Store transactions, derive positions, and put a reconciliation screen in front of the dashboard so a mismatch is visible on the day it appears rather than at the next tax year. ## Phase 2 · No valuation without its source and its age Every figure on the screen can be traced to a price, a source and a time. ### Steps 1. Stamp source and as-of on every price row A price without provenance is a rumour with two decimal places. Files: `src/prices/store.ts` 2. Put the oldest price age beside the headline It is the one number that tells you whether to trust the others. Files: `src/app/page.tsx` 3. Keep the whole thing working with the feed off Manual prices are the floor, not the fallback. Files: `src/prices/manual.ts`, `tests/no-feed.test.ts` ### Done when - [ ] Every valuation shows source and as-of - [ ] Stale prices are marked and the oldest age is on the headline - [ ] The app values everything with the feed disabled - [ ] A failed fetch never extends an existing price ### Watch out - Falling back to the last known price when a feed fails makes a portfolio valued on Friday look as though it was valued this morning, and the first time anybody notices is when a figure somebody acted on turns out to be a fortnight old. Stamp every price with its source and time, mark stale ones visibly, and put the age of the oldest price next to the headline total rather than behind a hover. ## Phase 3 · Projections made only of assumptions somebody typed The chart cannot be drawn until every input has been supplied and attributed. ### Steps 1. Ship no default growth rate An unowned number becomes everyone's number. Files: `src/projection/inputs.ts` 2. Render a band, never a line A single line is a forecast and you do not have one. Files: `src/projection/chart.tsx` 3. Attribute every assumption and carry them on the chart So the screenshot arrives with its own footnotes. Files: `src/projection/assumptions.ts`, `tests/projection.test.ts` ### Done when - [ ] The projection refuses to render with a missing input - [ ] No path draws a single-line projection - [ ] Assumptions record who set them and when - [ ] Exports carry the assumptions ### Watch out - Shipping a default growth rate means the chart somebody screenshots and shows their partner was produced by a number your application chose, and every conversation afterwards is about a figure nobody in the room decided. Ship none: leave the projection page empty until the assumptions are entered, attribute each one, and carry them on the face of the chart rather than in a panel underneath. ## Phase 4 · A planning page that expires Every tax figure has a source, a checked date and a review date, and the page stops rather than ages. ### Steps 1. Keep every figure in a dated rules file with a review date The figure and its expiry are one fact. Files: `data/rules/uk-2026.json`, `src/rules.ts` 2. Let the page refuse rather than render an old number Showing nothing is honest; showing last year's threshold is not. Files: `src/app/planning/page.tsx`, `tests/expiry.test.ts` 3. Bind the label to the figure in one component A disclaimer in the footer is a disclaimer nobody reads. Files: `src/components/TaxFigure.tsx` ### Done when - [ ] Every rule has source, checked date and review date - [ ] A passed review date hides the figure and names the source - [ ] Updates add rows rather than editing them - [ ] The label cannot render without the figure ### Watch out - Hard-coding a threshold that has been frozen for years feels safe precisely because it has not moved, and then a Finance Act extends the freeze again and your figure is right for the wrong reason until the year it silently is not. Put every figure in a dated file with its source, let the page refuse once the review date passes, and make updating the file a new row rather than an edit so an old estimate can still be explained. ## Not in this build - Any suggestion about a particular investment. - Segment-based suggestions. - Broker and bank connections. - A definitive tax calculation. ## After v1, if you want it - Corporate actions handled as transactions, since a share split silently breaks every derived position - A second jurisdiction's rules file, which is the real test of whether the rules abstraction holds - A dated snapshot of the whole household position, so a projection made last year can be scored against what happened
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. The ledger, the prices, the assumptions and the reviews. Back it up properly. DATABASE_PATH=./data/portfolio.db # Required. Dated tax rules files. Updates add rows; nothing is edited in place. RULES_PATH=./data/rules # Required. Past this a price is marked stale and its age appears on the headline. PRICE_STALE_HOURS=48 # Optional · secret. Optional. With it absent the application values everything from manual prices. PRICE_FEED_KEY=pk_...redacted # Optional · secret. The review. The only outbound call, and the only thing with a spend ceiling. MODEL_API_KEY=sk-...redacted # Required. Reviews stop at this figure. The tracker carries on without them. MODEL_MONTHLY_CEILING=2.00 # Required · secret. Encrypts the export. Losing it loses the export, not the database. EXPORT_PASSPHRASE=a-long-passphrase-redacted # Required. Everything is converted into this for totals, with the rate and its date stored. BASE_CURRENCY=GBP
# Portfolio Coach · product brief ## Problem A private dashboard for holdings, family members, projections, and CSV imports is a credible weekend build. Reliable live pricing for UK funds, a ledger-correct Vanguard importer, household data boundaries, maintained tax rules, and AI advice grounded in every account turn the complete product into a multi-day project. ## Product outcome A household tracker whose positions come from a ledger you can reconcile, whose every figure names its source and its age, whose projections are made only of assumptions somebody typed, and whose planning page stops rather than quietly showing last year's threshold. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node 22 - OpenAI-compatible API key for AI reviews - optional market-data API key - local SQLite database ## Explicit non-goals for v1 - Any suggestion about a particular investment. - Segment-based suggestions. - Broker and bank connections. - A definitive tax calculation. - reliable live pricing and UK fund symbol mapping - ledger-backed Vanguard history import - secure cloud accounts, family sharing, and support access - maintained UK tax and estate-planning assumptions - hosted AI reviews with current web context ## Success criteria - Derived positions are confirmed to reconcile against a real statement - No valuation is confirmed to render without its source and age - The projection is confirmed to refuse without a complete set of assumptions - A passed review date is confirmed to hide the planning figure - Adversarial prompts are confirmed to produce no instrument-level imperative
# Build brief · Portfolio Coach The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a private household portfolio tracker that describes what you hold and refuses to suggest what to do about it to replace Portfolio Coach. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero This one has a regulator, and the boundary is not where most builders assume it is. In the United Kingdom, article 53 of the Regulated Activities Order makes advising a specified activity where the advice is given to a person 'in his capacity as an investor or potential investor' and is 'advice on the merits' of buying, selling, holding or exchanging 'a particular investment'. Two elements: the merits, and a particular investment. The tempting move is to conclude that anything impersonal is therefore safe. That argument has just been narrowed from the other direction. A new article 55A, inserted with effect from 2026, created a regulated activity called providing targeted support, and the regulator's own rules describe it as delivering 'a ready-made suggestion by reference to the client's alignment with a pre-defined consumer segment', at scale, on limited information. Firms that wanted to make segment-level suggestions needed a permission created for the purpose. Checked at legislation.gov.uk and the regulator's policy statement on 15 September 2026. So the line this build draws is not about personalisation. It is: this tool describes, and does not suggest. It reports what the household holds, what it is worth, how concentrated it is, and what a projection does under assumptions the user typed in themselves. It never names an instrument to buy, sell, hold or switch, and there is no table it could store such a thing in. Second, the numbers go stale by being extended rather than by changing. The annual ISA subscription limit is 20,000 pounds for 2026 to 2027, and the inheritance tax nil rate band is 325,000 pounds with a residence band of 175,000 and a taper starting at 2 million, held there by successive Finance Acts and currently to 2030 to 2031. Checked on gov.uk on 15 September 2026. Every such figure lives in a dated rules file with a review date, and the planning page shows nothing rather than an old number once that date has passed. ### Stack (fixed, do not substitute) - Runtime: Next.js with TypeScript and SQLite, bound to localhost · a household's entire financial position should not need a server somebody else operates - Ledger: Transactions are the record; positions are derived and rebuildable · a position you typed in cannot be reconciled against a statement - Money: Integer minor units with a currency code, and every rate stored with its date · a portfolio in four currencies compared in floating point is wrong by an amount nobody can find - Rules: A dated tax rules file with a source and a review date, checked at render time · frozen thresholds get extended, and an extended threshold is a silently wrong number - Model: One call that describes and asks questions, with a refusal check on the output · the line between describing a portfolio and advising on it is a regulated one ### Data model (create this before Phase 1) - people(id, name, relationship) and accounts(id, person_id, wrapper, provider, currency, opened_on) - transactions(id, account_id, at, kind, instrument_id, units, price_minor, price_currency, fee_minor, fx_rate, fx_rate_date, source_row) · the record everything else is derived from - instruments(id, symbol, name, currency, kind, identifier_kind, identifier) · the identifier kind matters because the same fund answers to several - prices(instrument_id, as_of, price_minor, currency, source) · a price with no source and no date is not shown - positions_cache(account_id, instrument_id, as_of, units, book_cost_minor) · derived, rebuildable, never authoritative - assumptions(id, name, value, unit, set_by, set_at) · every projection input, attributed to the person who typed it - tax_rules(id, jurisdiction, name, value_minor, tax_year, source_url, checked_on, review_by) · the planning page refuses once the review date has passed - reviews(id, at, model_id, prompt_version, input_summary_json, output_text, refusals_json) · what was sent and what came back, because 'the app told me to' needs an answer - There is no table for a recommendation, a target allocation or a suggested trade. There is nowhere to put one, and that absence is the design. ### Phase 1 · A ledger you can reconcile, not a list of holdings Build: Model transactions with units, price, fee, and a conversion rate with its date. Derive positions and book cost. Import CSV with saved column mappings and a stable row hash. Add a reconciliation screen that compares derived units and value against figures typed from a statement. Trap: Letting someone type today's holdings and calling it a portfolio leaves no cost basis, no history and nothing to check against anything, so every gain figure is a guess and a double import goes unnoticed for a year. Store transactions, derive positions, and put a reconciliation screen in front of the dashboard so a mismatch is visible on the day it appears rather than at the next tax year. Done when: Derived positions match a reconciliation figure for a fixture account to the penny, tested. Importing the same file twice adds nothing, tested against the row hash. A transaction with a fee in one currency and a price in another produces a book cost that can be checked by hand, with the working shown. No position is directly editable anywhere in the interface, verified by review. Do not build yet: No pricing, projections, tax rules or model review yet. ### Phase 2 · No valuation without its source and its age Build: Support manual prices first and an optional feed second. Store source and as-of on every price. Mark anything past a configured age as stale and display the oldest price's age beside the household total. Keep the whole application working with the feed switched off. Trap: Falling back to the last known price when a feed fails makes a portfolio valued on Friday look as though it was valued this morning, and the first time anybody notices is when a figure somebody acted on turns out to be a fortnight old. Stamp every price with its source and time, mark stale ones visibly, and put the age of the oldest price next to the headline total rather than behind a hover. Done when: No valuation renders anywhere without its source and as-of time, verified by review and a test over the rendered pages. A stale price is visibly marked and the headline total carries the age of the oldest price behind it. With the feed disabled the application values everything from manual prices and says so. A price fetch failure never overwrites or extends an existing price row. Do not build yet: No projections, planning page or model review yet. ### Phase 3 · Projections made only of assumptions somebody typed Build: Make every projection input explicit: contributions, growth, inflation, withdrawals, retirement age, planned gifts. Attribute each to the person who entered it and the time. Render at least three scenarios as a band, never a single line. Ship no default growth rate at all. Trap: Shipping a default growth rate means the chart somebody screenshots and shows their partner was produced by a number your application chose, and every conversation afterwards is about a figure nobody in the room decided. Ship none: leave the projection page empty until the assumptions are entered, attribute each one, and carry them on the face of the chart rather than in a panel underneath. Done when: The projection refuses to render until every assumption has a value, tested by omitting each in turn. The output is a band from at least three scenarios and there is no code path that draws a single line, verified by review. Changing an assumption records who changed it and when. The assumptions appear on any exported image or file, tested by inspecting an export. Do not build yet: No tax rules, model review or household access boundaries yet. ### Phase 4 · A planning page that expires Build: Put every threshold and allowance in a dated rules file with a jurisdiction, a tax year, a source link, a checked date and a review date. Render the planning page from it. Once the review date has passed, show no figure at all and name what to check and where. Label the page informational in the same component that renders the number, not in a footer. Trap: Hard-coding a threshold that has been frozen for years feels safe precisely because it has not moved, and then a Finance Act extends the freeze again and your figure is right for the wrong reason until the year it silently is not. Put every figure in a dated file with its source, let the page refuse once the review date passes, and make updating the file a new row rather than an edit so an old estimate can still be explained. Done when: Every rule row has a source link, a checked date and a review date, enforced by a not-null constraint and a test. With the clock advanced past a review date, the planning page shows no figure and names the source to check, tested. Updating a figure adds a row and leaves the old one, tested. The informational label renders in the same component as the figure and cannot be displayed without it. Do not build yet: No model review or household access boundaries yet. ### Phase 5 · The line the software will not cross Build: Send only a computed summary to the model: aggregates, weights and the assumptions, with no names, account numbers or transaction rows. Constrain the prompt to describing and asking. Check the output for instrument-level imperatives before it renders and refuse rather than edit. Store every review with its prompt version and refusals. Add per-person access so a household member sees only the accounts they are linked to, and an encrypted export of everything. Trap: An open-ended 'review my portfolio' prompt returns a suggestion about a specific holding roughly every other time, because that is what a portfolio review looks like in the material the model learned from, and once it is on the screen the household has received something that reads exactly like advice from a tool sitting in their own house. Keep an adversarial prompt set in the suite, check the output before it renders, and refuse the whole response rather than deleting the offending sentence. Done when: A stored set of adversarial prompts produces no output naming a buy, sell, hold or switch of a specific instrument, run as a test. The payload sent to the model contains no names, account identifiers or transaction rows, asserted against a snapshot. Every review stores its prompt version, the summary sent and any refusals. A household member signing in sees only accounts they are linked to, tested with two people, and the encrypted export restores into an empty database. Do not build yet: Nothing after this. ### Out of scope (and why) - Any suggestion about a particular investment, which is the element article 53 turns on and the reason the schema has nowhere to store one. - Segment-based suggestions of the 'people like you usually' shape, which is precisely what the 2026 targeted support regime made a permissioned activity rather than a clever workaround. - Broker and bank connections, which mean holding credentials to other people's money and are a different category of responsibility from a tracker. - A definitive tax calculation. The planning page is an indicative figure from a dated file, and it stops showing anything once that file is out of date. ### README must contain - The two elements of article 53, quoted, with the source and the date checked, and the statement that this tool describes rather than suggests - That segment-level suggestions became a separately permissioned activity in 2026, so 'it is generic, therefore it is fine' is not the safe harbour it looks like - That every tax figure carries a source, a checked date and a review date, and that the page shows nothing once that date has passed - That positions are derived from transactions and that the reconciliation screen is how you check them - That the model receives aggregates only, and what the adversarial prompt test checks for
# Architecture · Portfolio Coach ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js with TypeScript and SQLite, bound to localhost | a household's entire financial position should not need a server somebody else operates | | Ledger | Transactions are the record; positions are derived and rebuildable | a position you typed in cannot be reconciled against a statement | | Money | Integer minor units with a currency code, and every rate stored with its date | a portfolio in four currencies compared in floating point is wrong by an amount nobody can find | | Rules | A dated tax rules file with a source and a review date, checked at render time | frozen thresholds get extended, and an extended threshold is a silently wrong number | | Model | One call that describes and asks questions, with a refusal check on the output | the line between describing a portfolio and advising on it is a regulated one | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Ledger | Transactions, derivation and reconciliation. | Any store, provided positions stay derived and the reconciliation screen exists. | | Prices | Manual and fed prices with source and age. | Any feed, provided a failure never extends an existing price. | | Projection | Assumptions, attribution and the band. | Any model, provided it ships no default and draws no single line. | | Rules | Dated figures with review dates. | Any format, provided the page refuses once the review date passes. | | Review | The payload, the prompt and the output check. | Any provider. Never swappable for an unchecked output. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · The ledger, the prices, the assumptions and the reviews. Back it up properly. - `RULES_PATH` · required · Dated tax rules files. Updates add rows; nothing is edited in place. - `PRICE_STALE_HOURS` · required · Past this a price is marked stale and its age appears on the headline. - `PRICE_FEED_KEY` · optional, secret · Optional. With it absent the application values everything from manual prices. - `MODEL_API_KEY` · optional, secret · The review. The only outbound call, and the only thing with a spend ceiling. - `MODEL_MONTHLY_CEILING` · required · Reviews stop at this figure. The tracker carries on without them. - `EXPORT_PASSPHRASE` · required, secret · Encrypts the export. Losing it loses the export, not the database. - `BASE_CURRENCY` · required · Everything is converted into this for totals, with the rate and its date stored. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Portfolio Coach product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js with TypeScript and SQLite, bound to localhost, Transactions are the record; positions are derived and rebuildable, Integer minor units with a currency code, and every rate stored with its date, A dated tax rules file with a source and a review date, checked at render time, One call that describes and asks questions, with a refusal check on the output. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Letting someone type today's holdings and calling it a portfolio leaves no cost basis, no history and nothing to check against anything, so every gain figure is a guess and a double import goes unnoticed for a year. Store transactions, derive positions, and put a reconciliation screen in front of the dashboard so a mismatch is visible on the day it appears rather than at the next tax year. - Falling back to the last known price when a feed fails makes a portfolio valued on Friday look as though it was valued this morning, and the first time anybody notices is when a figure somebody acted on turns out to be a fortnight old. Stamp every price with its source and time, mark stale ones visibly, and put the age of the oldest price next to the headline total rather than behind a hover. - Shipping a default growth rate means the chart somebody screenshots and shows their partner was produced by a number your application chose, and every conversation afterwards is about a figure nobody in the room decided. Ship none: leave the projection page empty until the assumptions are entered, attribute each one, and carry them on the face of the chart rather than in a panel underneath. - Hard-coding a threshold that has been frozen for years feels safe precisely because it has not moved, and then a Finance Act extends the freeze again and your figure is right for the wrong reason until the year it silently is not. Put every figure in a dated file with its source, let the page refuse once the review date passes, and make updating the file a new row rather than an edit so an old estimate can still be explained. - An open-ended 'review my portfolio' prompt returns a suggestion about a specific holding roughly every other time, because that is what a portfolio review looks like in the material the model learned from, and once it is on the screen the household has received something that reads exactly like advice from a tool sitting in their own house. Keep an adversarial prompt set in the suite, check the output before it renders, and refuse the whole response rather than deleting the offending sentence.
# Delivery milestones · Portfolio Coach Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A ledger you can reconcile, not a list of holdings Positions are derived from transactions and can be checked against a real statement. ### Steps 1. Make transactions the only writable record Everything else is a view and should be deletable without loss. Files: `src/db/schema.sql`, `src/positions.ts` 2. Put reconciliation in front of the dashboard A pretty number nobody has checked is worse than no number. Files: `src/app/reconcile/page.tsx`, `tests/reconcile.test.ts` 3. Store the conversion rate with its date on the transaction The rate on the day is part of the transaction, not a lookup. Files: `src/import/map.ts` ### Done when - [ ] Derived positions match a statement fixture exactly - [ ] A repeated import adds nothing - [ ] A multi-currency transaction shows its working - [ ] No position can be edited directly ### Watch out - Letting someone type today's holdings and calling it a portfolio leaves no cost basis, no history and nothing to check against anything, so every gain figure is a guess and a double import goes unnoticed for a year. Store transactions, derive positions, and put a reconciliation screen in front of the dashboard so a mismatch is visible on the day it appears rather than at the next tax year. ## M2 · No valuation without its source and its age Every figure on the screen can be traced to a price, a source and a time. ### Steps 1. Stamp source and as-of on every price row A price without provenance is a rumour with two decimal places. Files: `src/prices/store.ts` 2. Put the oldest price age beside the headline It is the one number that tells you whether to trust the others. Files: `src/app/page.tsx` 3. Keep the whole thing working with the feed off Manual prices are the floor, not the fallback. Files: `src/prices/manual.ts`, `tests/no-feed.test.ts` ### Done when - [ ] Every valuation shows source and as-of - [ ] Stale prices are marked and the oldest age is on the headline - [ ] The app values everything with the feed disabled - [ ] A failed fetch never extends an existing price ### Watch out - Falling back to the last known price when a feed fails makes a portfolio valued on Friday look as though it was valued this morning, and the first time anybody notices is when a figure somebody acted on turns out to be a fortnight old. Stamp every price with its source and time, mark stale ones visibly, and put the age of the oldest price next to the headline total rather than behind a hover. ## M3 · Projections made only of assumptions somebody typed The chart cannot be drawn until every input has been supplied and attributed. ### Steps 1. Ship no default growth rate An unowned number becomes everyone's number. Files: `src/projection/inputs.ts` 2. Render a band, never a line A single line is a forecast and you do not have one. Files: `src/projection/chart.tsx` 3. Attribute every assumption and carry them on the chart So the screenshot arrives with its own footnotes. Files: `src/projection/assumptions.ts`, `tests/projection.test.ts` ### Done when - [ ] The projection refuses to render with a missing input - [ ] No path draws a single-line projection - [ ] Assumptions record who set them and when - [ ] Exports carry the assumptions ### Watch out - Shipping a default growth rate means the chart somebody screenshots and shows their partner was produced by a number your application chose, and every conversation afterwards is about a figure nobody in the room decided. Ship none: leave the projection page empty until the assumptions are entered, attribute each one, and carry them on the face of the chart rather than in a panel underneath. ## M4 · A planning page that expires Every tax figure has a source, a checked date and a review date, and the page stops rather than ages. ### Steps 1. Keep every figure in a dated rules file with a review date The figure and its expiry are one fact. Files: `data/rules/uk-2026.json`, `src/rules.ts` 2. Let the page refuse rather than render an old number Showing nothing is honest; showing last year's threshold is not. Files: `src/app/planning/page.tsx`, `tests/expiry.test.ts` 3. Bind the label to the figure in one component A disclaimer in the footer is a disclaimer nobody reads. Files: `src/components/TaxFigure.tsx` ### Done when - [ ] Every rule has source, checked date and review date - [ ] A passed review date hides the figure and names the source - [ ] Updates add rows rather than editing them - [ ] The label cannot render without the figure ### Watch out - Hard-coding a threshold that has been frozen for years feels safe precisely because it has not moved, and then a Finance Act extends the freeze again and your figure is right for the wrong reason until the year it silently is not. Put every figure in a dated file with its source, let the page refuse once the review date passes, and make updating the file a new row rather than an edit so an old estimate can still be explained. ## M5 · The line the software will not cross (production only) The model describes and questions, the household's data stays inside, and everything exports. ### Steps 1. Send aggregates, never the ledger Snapshot the payload in a test so a later change is visible. Files: `src/review/payload.ts`, `tests/payload.test.ts` 2. Check the output and refuse the whole response Editing out a sentence leaves you with an edited recommendation. Files: `src/review/guard.ts`, `tests/adversarial.test.ts` 3. Scope each person to their own accounts A household tracker is several people's private information. Files: `src/auth/scope.ts`, `src/export.ts` ### Done when - [ ] Adversarial prompts produce no instrument-level imperative - [ ] The model payload carries no names or ledger rows - [ ] Reviews store prompt version, summary and refusals - [ ] Per-person access and encrypted export both hold ### Watch out - An open-ended 'review my portfolio' prompt returns a suggestion about a specific holding roughly every other time, because that is what a portfolio review looks like in the material the model learned from, and once it is on the screen the household has received something that reads exactly like advice from a tool sitting in their own house. Keep an adversarial prompt set in the suite, check the output before it renders, and refuse the whole response rather than deleting the offending sentence.
# Operations · Portfolio Coach ## Backup The database and the rules directory together, nightly and offsite, because a window into an old valuation can only be explained by the rules version that produced it. The encrypted export is the format that survives a schema change. ## Restore Restore both, rebuild the positions cache from transactions and confirm the reconciliation figures still match. A restore that recomputes a planning figure under today's rules rather than the stored ones has rewritten what the household was shown. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Age of the oldest price, rules rows within sixty days of their review date, reconciliation mismatches, and model spend against the ceiling. The second is the one that catches a quietly wrong planning page before somebody acts on it. ## Incident checklist The realistic incident is a household decision taken on a number that was stale or on a sentence that read like advice. The stored review with its prompt version, the price provenance and the rules row with its checked date are what let you say exactly what was shown and on what basis. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Derived positions are confirmed to reconcile against a real statement - [ ] No valuation is confirmed to render without its source and age - [ ] The projection is confirmed to refuse without a complete set of assumptions - [ ] A passed review date is confirmed to hide the planning figure - [ ] Adversarial prompts are confirmed to produce no instrument-level imperative ## Launch constraint Do not market omitted Portfolio Coach capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. The ledger, the prices, the assumptions and the reviews. Back it up properly. DATABASE_PATH=./data/portfolio.db # Required. Dated tax rules files. Updates add rows; nothing is edited in place. RULES_PATH=./data/rules # Required. Past this a price is marked stale and its age appears on the headline. PRICE_STALE_HOURS=48 # Optional · secret. Optional. With it absent the application values everything from manual prices. PRICE_FEED_KEY=pk_...redacted # Optional · secret. The review. The only outbound call, and the only thing with a spend ceiling. MODEL_API_KEY=sk-...redacted # Required. Reviews stop at this figure. The tracker carries on without them. MODEL_MONTHLY_CEILING=2.00 # Required · secret. Encrypts the export. Losing it loses the export, not the database. EXPORT_PASSPHRASE=a-long-passphrase-redacted # Required. Everything is converted into this for totals, with the rate and its date stored. BASE_CURRENCY=GBP
$ choose a build depth, inspect the files, then open the complete pack in your agent
The core tracker is free to start. Paid AI credits cover the convenient part: a hosted coach already connected to the household's live holdings, while the product maintains pricing, imports, security boundaries, and UK planning logic.
xreliable live pricing and UK fund symbol mapping
xledger-backed Vanguard history import
xsecure cloud accounts, family sharing, and support access
xmaintained UK tax and estate-planning assumptions
xhosted AI reviews with current web context
Portfolio Coach pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free portfolio tracking | $0 | $0 | Portfolio tracking/planning do not consume credit; £2 AI credit included (about $2.71 at the ECB 2026-08-12 reference rate); no card required |
| ai credit top-ups | custom | — | Prepaid top-ups start at £5 (about $6.76); AI usage is charged at the selected model provider's cost +20% |
free tier£2 of AI credit (about $2.71); portfolio tracking and planning do not consume credit; no card required
billingno subscription; prepaid AI credit top-ups from £5 (about $6.76)
hidden costsAI usage is variable and charged at the selected model provider's cost plus 20%; minimum top-up is £5 (about $6.76).
verified 2026-08-13 · source ↗
Vibecode Portfolio Coach
Kinda. The core of Portfolio Coach is buildable in a weekend with the prompt on this page, but there are real gaps: reliable live pricing and UK fund symbol mapping, ledger-backed Vanguard history import. Read the honest list above before committing.
How much does Portfolio Coach cost?
Portfolio Coach's pricing is usage-based or varies by plan · The portfolio tracker is free to start; AI use is funded with prepaid credits rather than a monthly subscription..
What do I lose by replacing Portfolio Coach?
Honestly: reliable live pricing and UK fund symbol mapping; ledger-backed Vanguard history import; secure cloud accounts, family sharing, and support access; maintained UK tax and estate-planning assumptions; hosted AI reviews with current web context. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Portfolio Coach?
Yes: Ghostfolio (Open-source wealth management software with portfolio analytics), Portfolio Performance (Open-source desktop portfolio tracker with detailed performance analysis). Using prior art is also vibecoding; the prompt is for when you want it exactly your way.