ADR-0027: named plan versions and ledger-marker depot snapshots
- Status: Accepted (owner sign-off 2026-07-16, decision gate per ADR-0026)
- Date: 2026-07-16
Context
The owner’s workflow (design conversation 2026-07-16): an allocation plan with target weights exists; the owner wants to restructure it. That means (a) editing a copy of the plan while the old one stays visible for reference, (b) freezing the depot state at the moment the strategy changes, and (c) later answering the founding question of the PRD’s Phase 5: “would I have performed better if I had stayed with the old plan?” — comparing the real (new-plan) performance against the counterfactual of having kept the old holdings.
Two constraints shape the design:
- Plans are single per scope today. Since ADR-0020
a target plan belongs to
(portfolio, view, classification)and a partial-NULL-aware unique index allows at most one plan per scope. Plans have no name and no status, so “old plan and new plan side by side in the same view” is not representable. - Holdings are never stored. Everything is a projection of the transaction ledger (ADR-0004); any historical depot state (quantities, cost basis) is exactly reproducible by projecting transactions up to a date. Copying trade history into a snapshot would duplicate the source of truth the architecture deliberately keeps single.
The full what-if simulator (FR-27, #332 — fictitious trades layered over real quote history) is explicitly not the goal of this decision. The owner needs the snapshot comparison first; the simulator remains a later phase and must be able to build on the primitives introduced here.
Decision
1. A depot snapshot is a ledger marker, not a copy
A snapshot is a named reference point: (name, view scope, as-of date) —
filesystem-snapshot semantics. It copies no transactions, quantities, or
prices. The snapshot’s holdings and cost basis are derived on demand by
projecting the ledger up to the as-of date within the view’s scope, exactly as
the existing holdings projection does for “today”. Snapshots are cheap to
create, cheap to delete, and can never drift from the ledger.
2. The v1 counterfactual is buy-and-hold, compared via TTWROR
The comparison answers “would I have done better if I had changed nothing?”:
- Snapshot side: the positions held at the as-of date, frozen (no trades, no flows), valued over the real stored quote history from the as-of date forward.
- Real side: the view’s actual TTWROR since the as-of date.
TTWROR neutralises deposits and withdrawals by construction, so the “how do we recognise fresh money vs. reinvestment?” problem does not arise in v1 — no flow classification is needed. The v1 comparison is gross and price-return only; dividends the frozen positions would have paid are a documented follow-up (requires per-security distribution history, which the app only has as the owner’s own booked transactions today). Every comparison surface labels this basis explicitly (as-of date, gross, price-return — UX-DR11).
Simulating “rebalancing as the old plan would have prescribed” is out of
scope here; it is the what-if simulator’s job (FR-27) and layers fictitious
scenario trades on top of a snapshot base later (scenario isolation per AR-8;
the audit_journal.scenario_id forward-index from
ADR-0017 anticipates exactly this).
3. Target plans become named, versioned entities
- A plan gains a name and a status:
active/draft/archived. - The ADR-0020 uniqueness tightens to: at most one active plan per
(portfolio, view, classification). Drafts and archived plans coexist freely in the same scope. - Duplicate-to-edit: duplicating a plan copies its target weights and cash
target into a new
draft; activating a draft archives the previously active plan. The allocation/drift engine keeps consuming exactly one plan — the active one — so ADR-0020’s coherence guarantees are untouched. - Migration is loss-free: every existing plan becomes
activewith a default name.
Note (2026-10-08, #1133): the target writes over the API and MCP
(PUT …/targets, the category and position-target deletes, and
portfolixir.targets.set, .delete and .delete_position) take an optional
plan_id, defaulting to the view’s active plan, so an agent edits a draft
between duplicating and activating it, as the SOLL editor does. An archived
plan is refused there: it steers nothing, and is reused by activating it or
duplicating it into a draft. The reads keep following the active plan, and
the SOLL editor’s own saves are unchanged.
4. Sequencing: journal-arming rides along
Plan writes are not yet journaled (open slice of the leaf-first
ADR-0017 rollout, noted in
Portfolios.TargetPlan). Introducing duplicate/activate/archive — new write
paths — without journaling would widen the auditability gap, so arming the
Targets/Plans context is part of the same epic, before or with the versioning
stories.
Consequences
- Positive: snapshots are near-free and always consistent with the ledger; the counterfactual needs no new stored financial data (NFR-2 holds trivially); the primitives (snapshot base, scenario column) are the foundation FR-27 builds on instead of throwaway work; plan history becomes explicit instead of living only in the audit journal’s before/after images.
- Negative / accepted: v1’s buy-and-hold counterfactual understates a disciplined old-plan strategy (no rebalancing simulation) and omits distributions — both are labeled on-surface and deferred deliberately.
- The quote history becomes load-bearing for past dates: valuing a snapshot needs quotes from the as-of date forward; gaps are surfaced with the existing gap-marker contract (AR-4), never silently interpolated.
References
- ADR-0004 — holdings derived, never stored
- ADR-0017 — audit journal,
scenario_idforward-index - ADR-0020 — view-bound target plans (uniqueness refined here)
- ADR-0026 — decision-gate workflow this ADR follows
- FR-27 / #332 — what-if simulator (later phase, builds on these primitives)
- Epic tracking: E16 in the Tracker Index of
_bmad-output/planning-artifacts/epics.md(the Epic Detail sections it formerly pointed at were removed by ADR-0042; this ADR is the spec)
Amendment: the comparison states its transaction costs (2026-08-15, owner decision)
§2 above labels the v1 counterfactual as gross and price-return only and names the dividend asymmetry as the follow-up. It is silent on a second asymmetry that is larger in the months right after a restructuring — which is exactly the window in which the comparison is read.
The two sides are not measured on one basis. The snapshot side takes no
trades and no flows: it pays nothing, ever. The real side is TTWROR, where
Portfolixir.Portfolios.Performance treats dividends, interest, fees and
taxes as internal — they are return, so every fee and tax actually booked
depresses it. The frozen side therefore wins by default for as long as the
trading costs of the change are unrecovered, and a reader cannot tell that from
the number.
The owner’s question that opened this (feedback triage 2026-08-15, Round 2) separates two things the single figure conflates: were the changes right? and have they paid for themselves yet? Those call for opposite responses, so one number cannot serve both.
1. What is subtracted, and what it is called
Transaction costs = the fees and taxes recorded on buy and sell
transactions falling inside the comparison window (as-of date → today), in the
comparison’s base currency.
Note (2026-10-06, #1051): a trade’s fees and taxes are converted to the base currency from its cash account’s currency, the currency a cross-currency trade records them in (ADR-0015), not from its price currency.
Two boundaries make this definition honest rather than convenient:
- Standalone
feeandtaxbookings stay internal. A custody fee or an account charge is not caused by a trade; the frozen holder would have paid it too. Only costs carried by a trade leave the return. - Dividend and interest taxation stays internal, with the dividend asymmetry it belongs to. Removing it here would half-fix the wrong gap.
The name matters and the working name was wrong. “Restructuring costs” claims the costs were caused by the restructuring — an attribution the arithmetic cannot establish, because the engine subtracts by rule and never knows why a trade happened. “Transaction costs” claims only what is true: a fee on a trade is caused by that trade. The narrower name also keeps the figure meaningful when nothing was restructured at all, which is the common case for a snapshot taken as a plain checkpoint.
2. The figure set
| Slot | Definition |
|---|---|
| Frozen return | Unchanged: buy-and-hold of the frozen holdings over real quote history |
| Real return | Unchanged: the scope’s TTWROR since the as-of date, net of everything |
| Real return before transaction costs | The same daily walk with window trade fees and taxes reclassified as external flows, so they neither reduce the return nor count as performance |
| Transaction costs | The sum itself, in base currency and in percentage points of the return |
The third slot reuses the existing walk with one changed flow classification — it introduces no second performance engine and no stored figure.
3. The recovery state
Three values, and the third is why the split is worth building:
- recovered — the real net return is ahead of the frozen one;
- partly recovered — the pre-cost return is ahead, the net one is not yet; the remaining gap is stated;
- not recovered — the pre-cost return is behind as well, so the costs are not the reason and the changes have not paid off on their own merits.
4. Rendering rule (review-blocking)
The pre-cost return is never rendered alone. It always carries the transaction-cost figure and the recovery state beside it, in the UI and in the API/MCP payload alike. On its own it is a number that flatters, and the value of this system rests on figures that can embarrass their author.
5. Computation basis in the payload
Per the identity gate’s requirement for level (a)–(c) analytics, the payload states: the window (as-of → today), which cost kinds were removed and which were kept, the base currency, and that the frozen side remains price-return only. The existing on-surface note carries the same statement without its ADR identifier — an internal decision number has no place in product copy (#704).
6. What this does not change
The buy-and-hold shape of the counterfactual, the ledger-marker snapshot, the dividend follow-up, and the out-of-scope status of rebalancing simulation (FR-27, #332) are all unchanged. This amendment adds a decomposition of the real side; it does not touch the frozen one.