Addresses PR #2046 review (superagent P1, Codex P2, jjmata):
- IDOR (P1): a statement could reference another plan's pension_source
via a crafted pension_source_id, leaking the source name + points
history. Goal::RetirementStatement now validates the source belongs
to the same plan.
- Adjustment cap was bypassable: the limit lived only on Goal::Retirement
(parent validations don't run on child saves), so the CRUD path allowed
an 11th. Goal::RetirementAdjustment now enforces it on create.
- Bucket account selection (and the show-page candidate list) now filter
through accounts.accessible_by(Current.user), so a private account
shared away from the user can't be added via a crafted POST.
- Comment clarifying the deliberate update_column in soft_replace!.
Tests for the IDOR guard + the child-level cap.
Functional data-entry surface on the (still preview) /retirement page.
The polished combined-page UI is PR4; this ships plain forms + lists so
a preview user can populate a plan end to end.
- RetirementScoped concern: tier-1 preview gate + tier-2 family
killswitch + per-owner plan bootstrap (Goal::Retirement.for_owner
find-or-creates, so children always have a parent). RetirementController
now uses it.
- Nested controllers under Retirement::: PensionSources (full CRUD),
Statements (new/create + soft-delete destroy — append-only audit),
Adjustments (full CRUD), Buckets (replace-all account selection,
same-family filtered). All scoped to the current user's own plan, so
cross-user access is impossible by construction.
- Routes nested under `resource :retirement` via `scope module:`.
- Views: show page rewritten into management sections (sources,
adjustments, bucket checkboxes, statement journal) + plain
styled_form_with forms. Money carries privacy-sensitive.
- Goal gains a target_amount_required? hook (true); Goal::Retirement
overrides it false — the forecast owns the target (PR3), so a plan
can exist before any target is set.
- EN locale for the new surface. 111 controller+model tests green.
Note: delete uses Turbo confirm for now; PR4 swaps in the skinned
DS::Dialog per the design.