{
  "prs": [
    {
      "number": 1089,
      "title": "Unpublish the FIRE-multiple article pending a currency fix",
      "body": "Takes `/blog/fire-number-multiple-by-country` offline, with its CSV, JSON, and chart. **Merge before the engine fix** \u2014 the numbers are wrong for 12 of 30 countries while it is live.\n\n## The defect\n\nThe dual-tax overlay computes the **US citizenship-country tax on the local-currency amount without converting to USD**. Holding real income fixed at $20,000:\n\n| Country | Currency | FX per USD | US citizenship tax |\n|---|---|---|---|\n| US | USD | 1.0 | **0.0%** |\n| Portugal | EUR | 0.9 | **0.0%** |\n| UK | GBP | 0.8 | **0.0%** |\n| Thailand | THB | 35.8 | **17.4%** |\n| Japan | JPY | 155 | **22.3%** |\n| Chile | CLP | 950 | **23.6%** |\n| Vietnam | VND | 25,000 | **23.8%** |\n\nEUR and GBP are near parity, so those households stay under the US standard deduction and correctly pay nothing \u2014 right by luck, not by logic. High-FX currencies land in the top US brackets, and **23.8% is exactly the 20% LTCG rate plus 3.8% NIIT**.\n\nExposed: Chile, Costa Rica, Hungary, Japan, Uruguay, Thailand, Czechia, South Africa, Mexico, Brazil, and marginally Malaysia and the UAE. Correcting it moves them **down** the ranking, so the published ordering was wrong \u2014 \"Thailand needs a larger multiple than the US\" does not survive.\n\nThe Netherlands mechanism (EUR) and the US $0 are unaffected and still reconcile by hand.\n\n## How it is taken down\n\nThe manifest is **renamed**, not deleted, so republishing is a rename once the numbers are correct. `glob(\"*.yaml\")` no longer matches it, so the route, sitemap, and RSS drop it and the URL returns the existing `noindex` 404. The article source, generator, and grid data stay in the repo. The dataset and chart are withdrawn rather than left downloadable under CC BY.\n\n## Two process failures, recorded in the log\n\n**My earlier Vietnam diagnosis was wrong.** Decomposing the vectorized result shows `residency_tax` is 25,000,000 VND \u2014 5%, correct, for both citizenships. Vietnam never charged 20%; that was the US side landing in a component *labelled* for investment income. A component label is not a component attribution.\n\n**Spec #1087 shipped a fix that fixes nothing.** `spec status: passed`, no functional code changed \u2014 one comment string, a version bump, and tests \u2014 and it bumped `assumptions_version` to `2026-08-13.v1` declaring citizenship-independence. Its tests pass because they exercise `compute_dual_tax`, the **scalar** path, which returns 5% for both citizenships and always did. The simulation uses the **vectorized** path. So the manifest now carries a false receipt, and any spec touching the tax engine must pin the vectorized path.\n\n259 blog/marketing tests pass with the article unpublished.\n\n\ud83e\udd16 Generated with [Claude Code](https://claude.com/claude-code)",
      "created_at": "2026-08-13T22:46:27Z",
      "merged_at": "2026-08-13T22:55:18Z",
      "merge_commit": "8829acd0181bab78756685eac32d09220828f405",
      "additions": 53,
      "deletions": 2792,
      "changed_files": 5,
      "files": [
        {
          "path": "content/blog/posts/fire-number-multiple-by-country.yaml.unpublished",
          "additions": 0,
          "deletions": 0,
          "changeType": "RENAMED"
        },
        {
          "path": "docs/distribution-program-log.md",
          "additions": 53,
          "deletions": 0,
          "changeType": "MODIFIED"
        },
        {
          "path": "frontend/public/blog-assets/country-fire-grid.csv",
          "additions": 0,
          "deletions": 121,
          "changeType": "DELETED"
        },
        {
          "path": "frontend/public/blog-assets/country-fire-grid.json",
          "additions": 0,
          "deletions": 2484,
          "changeType": "DELETED"
        },
        {
          "path": "frontend/public/blog-assets/country-fire-multiples.svg",
          "additions": 0,
          "deletions": 187,
          "changeType": "DELETED"
        }
      ],
      "repository_visibility": "private"
    },
    {
      "number": 1090,
      "title": "Convert dual-tax inputs at each policy currency boundary",
      "body": "## Summary\n\nFixes the dual-tax currency defect in both production paths.\n\n- converts every monetary scalar, vector, and nested breakdown field from the declared input currency into each policy currency before evaluation\n- converts residency and citizenship results back into the shared source currency before foreign-tax-credit comparisons\n- keeps same-currency cases bit-identical\n- adds exhaustive field-classification guards and real-FX scalar/vector regression oracles, including the high-FX countries that exposed the bug\n- records the functional model change as assumptions version `2026-08-14.v1`\n\n## Why this matters\n\nThe previous vectorized path treated local-currency amounts as USD on the US-citizenship side. High-FX currencies therefore pushed ordinary dollar-scale income into the top US capital-gains and NIIT brackets. EUR and GBP appeared correct only because their exchange rates are near one.\n\nThis PR supplies the core policy-boundary conversion and the strongest tax-oracle coverage. #1091 is stacked on it and adds frozen run-level FX provenance, explicit execution/replay plumbing, and cross-currency relocation coverage.\n\n\ud83e\udd16 Generated with [Claude Code](https://claude.com/claude-code)",
      "created_at": "2026-08-13T22:54:25Z",
      "merged_at": "2026-08-14T02:38:36Z",
      "merge_commit": "6ad6753d52007be5f597427f7f0888bea788e402",
      "additions": 1010,
      "deletions": 80,
      "changed_files": 12,
      "files": [
        {
          "path": "assumptions_version.py",
          "additions": 47,
          "deletions": 0,
          "changeType": "MODIFIED"
        },
        {
          "path": "specs/dual-tax-citizenship-currency-conversion.md",
          "additions": 140,
          "deletions": 0,
          "changeType": "ADDED"
        },
        {
          "path": "tax_engine/dual_tax.py",
          "additions": 414,
          "deletions": 13,
          "changeType": "MODIFIED"
        },
        {
          "path": "tests/conftest.py",
          "additions": 70,
          "deletions": 0,
          "changeType": "MODIFIED"
        },
        {
          "path": "tests/test_assumptions_version.py",
          "additions": 11,
          "deletions": 1,
          "changeType": "MODIFIED"
        },
        {
          "path": "tests/test_dual_tax.py",
          "additions": 281,
          "deletions": 56,
          "changeType": "MODIFIED"
        },
        {
          "path": "tests/test_hungary_tax.py",
          "additions": 23,
          "deletions": 5,
          "changeType": "MODIFIED"
        },
        {
          "path": "tests/test_mcp_core.py",
          "additions": 3,
          "deletions": 3,
          "changeType": "MODIFIED"
        },
        {
          "path": "tests/test_mcp_funnel.py",
          "additions": 1,
          "deletions": 1,
          "changeType": "MODIFIED"
        },
        {
          "path": "tests/test_tax_engine_rich_breakdown.py",
          "additions": 12,
          "deletions": 1,
          "changeType": "MODIFIED"
        },
        {
          "path": "tests/test_tax_gross_securities_proceeds.py",
          "additions": 4,
          "deletions": 0,
          "changeType": "MODIFIED"
        },
        {
          "path": "tests/test_vietnam_tax.py",
          "additions": 4,
          "deletions": 0,
          "changeType": "MODIFIED"
        }
      ],
      "repository_visibility": "private"
    },
    {
      "number": 1091,
      "title": "Freeze and propagate dual-tax FX context",
      "body": "## Summary\n\nBuilt on #1090 and intentionally preserves its exhaustive policy conversion and real-FX tax oracles. This PR adds the execution guarantees needed for reproducible live runs.\n\n- declares the scenario/source currency explicitly at scalar and vector tax boundaries\n- freezes a provider-backed `RateTable` at enqueue, persists its receipt, and reuses it in worker execution, artifacts, sweeps, Roth exploration, grids, and deterministic replay\n- fails queued cross-currency runs closed when provider rates are unavailable; direct legacy calls retain a visible approximate fallback\n- routes cross-currency relocation years through the same conversion boundary even when no US-citizenship overlay applies\n- records the execution/replay semantic change as assumptions version `2026-08-14.v2`\n\n## Relationship to #1090\n\n#1090 provides the core exhaustive conversion, same-currency identity behavior, and broad scalar/vector policy oracles. This PR adds frozen rate provenance and closes the surrounding execution, replay, and relocation gaps. The combined tree keeps both sets of protections.\n\n## Validation\n\n- `make test` \u2014 5,181 passed, 8 skipped\n- `make lint` \u2014 Alembic preflight, Ruff, mypy, Grafana validation, frontend typecheck/ESLint, and Reddit app typecheck/build all passed\n- post-rebase publication/assumptions sanity slice \u2014 8 passed, 2 skipped",
      "created_at": "2026-08-14T01:06:12Z",
      "merged_at": "2026-08-14T02:59:56Z",
      "merge_commit": "2f09182c7e2ea1d38cee86c5313e93a7f2cb05d9",
      "additions": 651,
      "deletions": 185,
      "changed_files": 24,
      "files": [
        {
          "path": "api/routers/runs.py",
          "additions": 76,
          "deletions": 0,
          "changeType": "MODIFIED"
        },
        {
          "path": "api/routers/scenarios.py",
          "additions": 37,
          "deletions": 3,
          "changeType": "MODIFIED"
        },
        {
          "path": "assumptions_version.py",
          "additions": 13,
          "deletions": 0,
          "changeType": "MODIFIED"
        },
        {
          "path": "simulation_engine/core.py",
          "additions": 67,
          "deletions": 15,
          "changeType": "MODIFIED"
        },
        {
          "path": "simulation_engine/detail.py",
          "additions": 6,
          "deletions": 0,
          "changeType": "MODIFIED"
        },
        {
          "path": "simulation_engine/result_artifact_materializer.py",
          "additions": 12,
          "deletions": 2,
          "changeType": "MODIFIED"
        },
        {
          "path": "simulation_engine/roth_explorer_run.py",
          "additions": 14,
          "deletions": 3,
          "changeType": "MODIFIED"
        },
        {
          "path": "simulation_engine/tasks.py",
          "additions": 33,
          "deletions": 11,
          "changeType": "MODIFIED"
        },
        {
          "path": "simulation_worker.py",
          "additions": 16,
          "deletions": 10,
          "changeType": "MODIFIED"
        },
        {
          "path": "specs/dual-tax-citizenship-currency-conversion.md",
          "additions": 18,
          "deletions": 18,
          "changeType": "MODIFIED"
        },
        {
          "path": "storage/repository.py",
          "additions": 18,
          "deletions": 2,
          "changeType": "MODIFIED"
        },
        {
          "path": "tax_engine/dual_tax.py",
          "additions": 94,
          "deletions": 70,
          "changeType": "MODIFIED"
        },
        {
          "path": "tests/conftest.py",
          "additions": 20,
          "deletions": 9,
          "changeType": "MODIFIED"
        },
        {
          "path": "tests/test_assumptions_version.py",
          "additions": 16,
          "deletions": 1,
          "changeType": "MODIFIED"
        },
        {
          "path": "tests/test_dual_tax.py",
          "additions": 7,
          "deletions": 3,
          "changeType": "MODIFIED"
        },
        {
          "path": "tests/test_mcp_core.py",
          "additions": 3,
          "deletions": 3,
          "changeType": "MODIFIED"
        },
        {
          "path": "tests/test_mcp_funnel.py",
          "additions": 1,
          "deletions": 1,
          "changeType": "MODIFIED"
        },
        {
          "path": "tests/test_relocation_events.py",
          "additions": 75,
          "deletions": 16,
          "changeType": "MODIFIED"
        },
        {
          "path": "tests/test_scenario_detail.py",
          "additions": 1,
          "deletions": 1,
          "changeType": "MODIFIED"
        },
        {
          "path": "tests/test_simulation_core.py",
          "additions": 13,
          "deletions": 2,
          "changeType": "MODIFIED"
        },
        {
          "path": "tests/test_simulation_tasks.py",
          "additions": 11,
          "deletions": 4,
          "changeType": "MODIFIED"
        },
        {
          "path": "tests/test_simulation_worker.py",
          "additions": 7,
          "deletions": 1,
          "changeType": "MODIFIED"
        },
        {
          "path": "tests/test_tax_currency_run_context.py",
          "additions": 67,
          "deletions": 0,
          "changeType": "ADDED"
        },
        {
          "path": "tests/test_tax_engine_rich_breakdown.py",
          "additions": 26,
          "deletions": 10,
          "changeType": "MODIFIED"
        }
      ],
      "repository_visibility": "private"
    }
  ],
  "spec": {
    "path": "specs/dual-tax-citizenship-currency-conversion.md",
    "commit": "2f09182c7e2ea1d38cee86c5313e93a7f2cb05d9",
    "text": "---\nid: dual-tax-citizenship-currency-conversion\ndepends_on: []\narea: backend\ndescription: The dual-tax overlay evaluates the citizenship country's policy on source-currency amounts, inflating tax for every non-parity currency.\n---\n\n## Goal\n\nThe dual-tax overlay hands the **citizenship** country's tax policy amounts denominated\nin the scenario's source currency, with no conversion anywhere in\n`tax_engine/dual_tax.py`. The citizenship policy's brackets, allowances, and thresholds\nare denominated in its own currency, so the numbers are read as the wrong unit. It never\nraises; it returns a plausible figure from the wrong part of the bracket table.\n\nThis affects every US-citizen scenario resident in a country whose currency is far from\nparity with USD \u2014 which is most of the supported registry.\n\n## Evidence\n\nReal income held fixed at USD 20,000 of investment income, US citizenship, married filing\njointly, varying only the residency country. `citizenship_tax`, identical from\n`evaluate_dual_tax_vec` and `compute_dual_tax`. Panama is the USD control: it exercises\nthe overlay (`_overlay_applies` needs `citizenship == \"US\"` and a different residency\ncountry) with no conversion to do, where a US-residency row would be vacuous.\n\n| Residency | Currency | FX per USD | Local income | US citizenship tax | % of income |\n|---|---|---|---|---|---|\n| Panama | USD | 1.0 | 20,000 | 0 | **0.0%** |\n| Portugal | EUR | 0.92 | 18,400 | 0 | **0.0%** |\n| UK | GBP | 0.79 | 15,800 | 0 | **0.0%** |\n| Thailand | THB | 35.8 | 716,000 | 124,896 | **17.4%** |\n| Japan | JPY | 155 | 3,100,000 | 692,288 | **22.3%** |\n| Chile | CLP | 950 | 19,000,000 | 4,476,488 | **23.6%** |\n| Vietnam | VND | 25,000 | 500,000,000 | 118,954,488 | **23.8%** |\n\nThe charge rises monotonically with the exchange rate \u2014 the signature of an unconverted\namount. **23.8% is exactly the 20% US long-term capital-gains rate plus 3.8% NIIT**, the\nrate a household reaches when its income is read as half a billion dollars.\n\nEUR and GBP are near parity, so those households stay under the US standard deduction and\npay nothing. They are correct by luck, not by logic, and that is why this survived\nreview: the countries anyone spot-checked were the ones the bug could not reach.\n\n## Design\n\nThe caller declares the scenario's source currency and supplies the immutable\n`RateTable` frozen when the run was enqueued. Convert the shared input independently\ninto each policy country's currency, evaluate each policy there, and convert both\nresults back into the source currency before the foreign-tax-credit comparison. The\nsource currency must not be inferred from active residency: residency can change while\nthe engine continues to carry the scenario's original unit.\n\n## Acceptance Criteria\n\n- [x] For a fixed real income, the citizenship-country tax is invariant to the residency\n      country's currency. The table above becomes 0.0% across every row, because a\n      married couple's standard deduction exceeds USD 20,000 of dividends everywhere.\n- [x] The conversion is **exhaustive over the scalar input**, `AnnualTaxInput`: roughly\n      twenty monetary fields plus nested `IncomeSourceBreakdown`,\n      `InvestmentIncomeBreakdown`, `CapitalGainsBreakdown`, `PropertyWealthFacts`, and\n      `SecuritiesDisposalBreakdown` dataclasses. Converting a subset is the same class of\n      defect as converting none. A test enumerates the monetary fields and fails when a\n      new one is added without being handled.\n- [x] The conversion is **separately exhaustive over the vector representation**.\n      The production path converts `_VectorAnnualInput` and `_StackedVectorInputs`, plus\n      the standalone `taxable_securities_total_gross_proceeds` and\n      `taxable_securities_realized_gains` arrays threaded alongside them. These are\n      distinct structures from `AnnualTaxInput`, so a complete scalar conversion can\n      coexist with a partial vector one. Enumerate and test both independently.\n- [x] Non-monetary fields are never scaled: `year_index`, `calendar_year`, wrapper ids,\n      eligibility states, provenance strings, and any ratio or fraction.\n- [x] The foreign-tax-credit comparison `min(residency_tax, citizenship_tax)` happens\n      **after** the citizenship side is converted back, so both operands share a\n      currency. Assert this directly \u2014 a same-currency comparison is the whole point of\n      the credit being meaningful.\n- [x] Both `compute_dual_tax` and `evaluate_dual_tax_vec` are fixed, and the tests pin\n      **both** \u2014 they carry the defect identically, and the vectorized one is what\n      `simulation_engine/core.py` runs. Tests must also assert the **citizenship**\n      component, not only the residency attribution: asserting residency is how\n      `2026-08-13.v1` passed while shipping nothing, since both paths already report the\n      residency side correctly.\n- [x] Results are unchanged for a USD-currency residency country that still exercises the\n      overlay \u2014 **Panama with US citizenship**, not US residency, since\n      `_overlay_applies` requires `citizenship == \"US\"` and a different residency country,\n      making a US-residency control vacuous \u2014 and for currencies within a few percent of\n      USD parity. This proves the change is inert where the bug could not reach.\n- [x] **The FX rate is frozen, versioned, and carried as a receipt on the run.**\n      `exchange_rate_service.convert()` reads the live-or-cached table, and ordinary\n      personal runs do not currently persist a frozen table \u2014 only MCP sweeps thread one\n      through the worker. A tax result that depends on whatever happened to be cached when\n      a worker executed is not reproducible, and a deterministic replay of the same run\n      could return a different tax. Pin the rate at enqueue, persist it with the run, and\n      have replay read the stored table rather than re-fetching.\n- [x] A new `assumptions_version` manifest entry is added. This moves modelled results\n      for most non-US residency countries, so it is unambiguously a governed change.\n- [x] `make test` and `make lint` pass.\n\n## Out of Scope\n\n- Changing any country's statutory rates, brackets, or allowances.\n- The foreign-tax-credit limitation rules themselves, beyond doing the comparison in a\n  single currency.\n- Redesigning the exchange-rate service itself, beyond giving the overlay a frozen,\n  versioned rate source and a receipt (see the criterion above).\n\n## Danger Zones\n\n- **A partial conversion is worse than none.** None is at least uniform and detectable;\n  a subset silently mixes units inside one evaluation. Enumerate the fields.\n- **Rate determinism is not already solved.** It is tempting to assume priming the cache\n  is enough. It is not: the primed table is process state, not a property of the run, so\n  two executions of the same stored scenario can disagree. This defect converts an FX\n  rate into a tax result, which makes the rate part of the answer and therefore part of\n  the provenance. Treat it as such.\n- **Blast radius.** This moves numbers for most residency countries, including results\n  users have already seen in the app and results that were published. The published\n  country FIRE-multiple dataset was withdrawn precisely because of this defect and is\n  regenerated downstream of this spec.\n- **Do not \"fix\" this by special-casing Vietnam or high-FX currencies.** The defect is\n  structural; the currency is only what makes it visible.\n\n## Agent Notes\n\nFound while validating why 30 of 30 published production runs returned a lower success\nrate than the local grid. The decomposition that cracked it was separating `residency_tax`\nfrom `citizenship_tax` in `DualTaxVectorResult` \u2014 the component labelled\n`investment_income_tax` aggregates both sides, so the inflated figure looked like a\nVietnam rate defect for some time. A component label is not a component attribution.\n\n`tax_engine/dual_tax.py` now carries a module docstring stating the currency contract and\nboth traps; keep it accurate if the design changes. The module has also been added to\n`GOVERNED_SEMANTIC_MODULES` \u2014 it was outside the fingerprint, so overlay changes could\nmove modelled output without an `assumptions_version` entry.\n\nBoth `compute_dual_tax` and `evaluate_dual_tax_vec` carry this defect identically\n(Thailand 17.4%, Vietnam 23.8% on both). The reason `2026-08-13.v1` missed it is not that\nit tested the scalar path \u2014 it is that it asserted the **residency** attribution, which\nboth paths already report correctly, while the bad value was on the citizenship side\nselected into the aggregate investment-tax component.\n"
  },
  "withdrawal_to_core_fix_seconds": 13398,
  "timing_definition": "GitHub merge times, not deployment or republication timestamps."
}
