Files
budget2.0/docs/specs/2026-09-02-versioned-allocation-rules-design.md
T
Lucas WintherandClaude Opus 5 d83edccc79 docs: move the long-form docs into docs/, without the superpowers nesting
ARCHITECTURE, PRODUCT and TODO move out of the repository root, and
docs/superpowers/{specs,plans} flattens to docs/{specs,plans}:
"superpowers" names the tool that wrote those documents, not a kind of
document.

No content changes. This commit is the move, plus the roughly thirty
cross-references the move invalidated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 21:56:59 +02:00

224 lines
9.5 KiB
Markdown
Executable File

# Time-versioned allocation rules — Design
**Date:** 2026-09-02
**Status:** Awaiting review
**Extends** `2026-09-01-budget-app-design.md`. Every invariant there still holds,
with one deliberate replacement noted below.
## Purpose
A post's allocation changes over time: Food is 10% from April, then 15% from
July. Today a post has exactly one `standingRule` covering all of history, plus
a per-month override. Expressing "15% from July onward" currently means editing
every month individually, and the fact that it was a deliberate change is lost.
Rules become a dated series. A version takes effect in its month and continues
until the next version replaces it.
This model was offered during the original design and declined in favour of the
simpler standing rule. It is being adopted now because using the app showed the
simpler model does not survive contact with a budget that evolves.
## Non-goals
- **Versioning anything else.** Post name, currency and archived status stay
single-valued. Only the allocation rule is time-varying.
- **Retroactive recalculation prompts.** Adding a version dated in the past
changes past allocations, and therefore every later balance, because the
rollover fold is a left fold. That is correct and intended; the app does not
warn about it beyond showing the new numbers.
- **Reworking the fold.** `allocationFor` keeps its signature; only its
internals change.
## Decisions and why
### Dated series AND per-month overrides both survive
They mean different things, and collapsing them loses information:
- A **version** is a permanent change from month X onward: "Food is 15% from
July."
- An **override** is a one-month exception that leaves the series intact:
"December only, 3000 for Christmas."
Expressing a one-month exception as versions needs two entries — change, then
change back — and forgetting the second silently makes an exception permanent.
### No rule yet means an allocation of zero
If a post's earliest version starts in 2026-04, its allocation for every month
before that is 0. The post genuinely had no budget then. Spending in those
months still records and shows as overspend against a zero envelope, so nothing
is concealed.
Rejected: applying the earliest rule backwards. It invents budget for months the
user never budgeted, and the rollover fold would then carry that fictional
surplus forward into real months.
### The series lives on the post
`Post.rules` rather than a top-level `Dataset.ruleVersions` collection. The
fold reads a post's rule for every post in every month, so a top-level
collection would mean filtering by `postId` on the hottest path in the app, and
would separate a rule from the thing it governs when archiving or exporting.
Rejected: keeping `standingRule` and adding a map of later changes. It cannot
express "no rule yet" — a standing rule always exists — so it contradicts the
zero-before-start decision, and leaves two concepts describing one thing.
## Domain model
```ts
interface RuleVersion {
/** The rule takes effect in this month and continues until the next version. */
from: MonthId;
rule: Rule;
}
interface Post {
// ...unchanged...
rules: RuleVersion[];
}
```
`Post.standingRule` is **removed**, replaced by `rules`.
### Invariants
- `rules` is sorted ascending by `from`.
- **At most one version per month.** Setting a rule for a month that already has
one replaces it; two versions sharing a `from` would make the effective rule
ambiguous.
- An empty `rules` array is legal and means the post has never been budgeted.
- A `from` earlier than `settings.foldStartMonth` is legal but inert: the fold
never evaluates months before its start, so such a version simply has no
effect until the fold start moves earlier.
**A deliberate difference between new and migrated data.** Seeded posts start
with `rules: []` — a new user has genuinely not budgeted anything yet. Migrated
posts get one version carrying their existing rule, which for untouched seed
data is a zero fixed amount. So a fresh install shows "not budgeted" where a
migrated install shows an explicit 0. Those were indistinguishable under the old
model and are now different states; keeping them distinct is correct, because a
migrated user really did have a rule and a new user really does not.
### Resolution
`ruleAt(post, monthId): RuleVersion | null` returns the last version whose
`from <= monthId`, or `null` when none applies.
`allocationFor` resolves in strict precedence:
1. `month.ruleOverrides[postId]` — one-month exception, wins outright
2. `ruleAt(post, monthId)` — the effective version
3. neither -> **0**
`allocationFor` keeps its current signature, so `foldBalances`, the view models
and the ODS export are untouched.
`isOverridden` keeps its current meaning — an explicit one-month exception — and
continues to drive the month view's badge.
## Migration: schemaVersion 1 -> 2
```
{ standingRule } -> { rules: [{ from: settings.foldStartMonth, rule: standingRule }] }
```
This is the project's first real migration. It is a pure shape change with no
arithmetic, and it is **behaviour-preserving by construction**: today's standing
rule already applies from the fold start onward, so every month resolves to the
allocation it resolved to before.
Existing `Month.ruleOverrides` are untouched.
The migration runs in the browser against the user's only copy of their data.
Two things contain the risk: `migrate()` already refuses to open data written by
a newer build, so a half-upgraded state cannot silently corrupt; and the
behaviour-preserving property is asserted by test rather than assumed.
## UI
Two editing surfaces, both writing through the same `setRuleFrom` action, so
they cannot drift out of step with each other.
### Settings — the history editor
Each post row shows the rule **in effect for the current calendar month**
(Settings has no month context of its own), with a control
opening a chronological editor: a list of `from` month + rule, with add and
remove. A post with no rules shows "not budgeted" rather than a zero rule, since
those are different states.
Adding a version for a month that already has one replaces it, and the editor
says so rather than silently keeping the last write.
### Month view — change from here
Each post row gains a control that creates a version starting **that** month,
because the natural moment to decide "15% from July" is while looking at July.
It writes the same `setRuleFrom` action the history editor uses.
It opens pre-filled with the rule currently in effect for that month, so the
common act of nudging 10% to 15% is an edit rather than re-entry. If the post
has no rule yet, it opens empty.
The month view distinguishes the two kinds of change: an override keeps its
existing badge; a month where a version *begins* is marked separately, so a step
in the numbers has a visible cause. A month can carry both markers at once — a
version starting in a month the user also overrode is unusual but legal, and
showing only one would hide the reason the number is what it is. The override
still wins the allocation, per the precedence above.
## Actions
- `setRuleFrom(draft, postId, from: MonthId, rule: Rule)` — inserts or replaces
the version for that month, keeping `rules` sorted. Rounds the rule at the
mutation boundary like every other money-shaped write.
- `removeRuleFrom(draft, postId, from: MonthId)` — deletes that version. Removing
the earliest one means earlier months resolve to 0.
## Validation
`parseDatasetJson` must, for each post: accept a `rules` array; reject a
malformed or out-of-range `from` month using the existing tightened month
pattern; and reject duplicate `from` months, since the resulting effective rule
would be ambiguous.
Over-budget remains always permitted: no rule value is rejected for magnitude,
and percentages above 100% stay legal.
## Testing
- `ruleAt`: before the first version -> `null`; exactly on a version's month;
between two versions; after the last.
- `allocationFor`: no rule -> 0; an override beats the effective version; a
version applies from its month onward.
- **The fold across a rule change** — 10% through June, 15% from July — asserting
the allocation steps in July and that the rollover carries correctly across the
boundary. This is the test that proves the feature, because the fold is what
every view reads.
- **The migration preserves behaviour.** Build a v1 dataset, migrate it, and
assert *every month's allocation is identical before and after*. A shape-only
test would pass while balances moved; this is the property that matters.
- JSON import accepts v2, migrates v1, and rejects duplicate `from` months.
- `setRuleFrom` replaces rather than appends for a month that already has a
version, and keeps the array sorted.
- Archived posts still resolve rules, since they still fold.
Not covered: anything visual in either editing surface, and the real IndexedDB
upgrade path. Both need a browser.
## Files
| File | Change |
|---|---|
| `src/domain/types.ts` | `RuleVersion`; `Post.rules` replaces `standingRule` |
| `src/domain/allocation.ts` | `ruleAt`; precedence in `allocationFor` |
| `src/domain/seed.ts` | seed posts start with empty `rules`; `SCHEMA_VERSION` 2 |
| `src/store/migrations.ts` | the v1 -> v2 migration |
| `src/store/actions.ts` | `setRuleFrom`, `removeRuleFrom`; `addPost`/`updatePost` |
| `src/export/json.ts` | validate `rules`, reject duplicate months |
| `src/ui/routes/settings/PostsSection.tsx` | history editor |
| `src/ui/routes/MonthRoute.tsx`, `src/ui/components/PostTable.tsx` | change-from-here control; version-starts-here marker |