Goal envelopes — representation and release semantics
Date: 2026-07-03 · Status: Decided · Epic: G-1
Branch: feat/mvp1-personas-authz
Goals are fundable wallet envelopes, not a derived fold of the save bucket. This document
records the representation choice, verb design, release semantics, and deliberate divergences
from the POC (../Chore_app).
Decision: goals = ledger ownership dimension
A goal is an envelope whose balance is derived from the append-only ledger — exactly as
a member's Wallet is derived, never stored. The implementation choice is representation (a)
— nullable goal_id on ledger_entries.
A ledger row with goal_id != null belongs to the goal's envelope. member_id stays on the
row as the contributor (the POC's original_owner_id analog). All member-bucket folds
(walletOf / balanceOf / _balanceIn) filter goal_id IS NULL; the envelope fold filters
goal_id = X.
| Dimension | Value |
|---|---|
| Envelope balance | Σ delta where goal_id = X (derived, never stored) |
| Per-contributor net | Same fold grouped by member_id |
| bucket on an envelope leg | Source bucket for fund legs; destination bucket for withdraw legs; release legs stamp general (audit trail) |
| Schema change | Additive only: alter table ledger_entries add column goal_id uuid references goals(id) on delete cascade + partial index |
Rejected alternatives
| Alternative | Why not |
|---|---|
| Per-goal Bucket values | Unbounded bucket vocabulary; CHECK churn per goal; walletOf switch breaks |
goal_funds side table | Second token truth; conservation/zero-floor/undo machinery needs twins outside the ledger |
| POC token-batch ownership | Rewhaven's token_batches is reserved for Tier-2 fade lots; batch FIFO/splitting abandons the append-only model chosen deliberately |
The verb pair: fundGoal / withdrawFromGoal
moveFunds(from: Bucket, to: Bucket) cannot type a goal destination without a sentinel or
union param. Dedicated verbs keep moveFunds + its existing tests untouched and mirror its
exact structure (debit-first, pair of kind: move legs, shared refId, all-or-nothing).
fundGoal(memberId, goalId, from: Bucket, amount, [actingMemberId])
→ debit: member bucket (goalId: null)
← credit: goal envelope (goalId: X, bucket = source)
withdrawFromGoal(memberId, goalId, to: Bucket, amount, [actingMemberId])
→ debit: goal envelope (goalId: X)
← credit: member bucket (goalId: null, bucket = to)
withdrawFromGoal is the exact inverse of fundGoal — undo-as-inverse works with no SDK
undo verb, identical to the wallet move pattern.
Guards
amount > 0- Goal must exist AND
status == GoalStatus.active(funding a requested/complete/archived goal throwsDomainRuleException) - Member-scope goal:
goal.memberId == memberId(contributor funds their OWN goal; cross-member gifting deferred — no POC precedent, release attribution ambiguity) - Family-scope goal: any household member may contribute from their own tokens
- Three-floor trigger (SQL): goal
IS NULLbranch enforces existing member+bucket floor; goalIS NOT NULLbranch enforces envelope floor AND contributor-net floor - Authorization: existing
_authorizeMove(memberId, actingMemberId)reused verbatim — funds always leave the contributor's own wallet, so self/on-behalf semantics apply unchanged
Release on complete AND archive
When completeGoal or archiveGoal is called, the envelope is released back to each
contributor's general bucket (the deliberate-allocation inbox; the POC's bank analog):
- For each contributor with
net > 0: append a compensating pair — debitgoal_idleg + creditgeneralleg (kindmove, sharedrefIdper contributor) - Release is idempotent — releasing an empty envelope is a no-op
restoreGoalafter archive: envelope restarts at 0 (funds were returned at archive time)- Funds are never consumed — reaching or archiving a goal is not a spend event; the normal general → spend → redeem flow is the deliberate next step
This is exact POC parity (_rehomeGoalBatches on complete AND archive → bank; idempotent).
Guard
NEVER add a hard deleteGoal verb without releasing first. The live ON DELETE CASCADE
on goal_id would burn envelope tokens if a goal row were deleted before its ledger legs were
released. The SDK exposes no deleteGoal facade; household teardown fires the cascade only when
the entire ledger dies with it.
Deliberate divergences from the POC
| POC behaviour | Rewhaven choice | Rationale |
|---|---|---|
Source = bank only (allocateFromBank) | Any of the four buckets | Rewhaven move UX is already any-bucket → any-bucket; goals are one more deliberate destination |
| No user-facing partial withdraw | Real withdrawFromGoal verb | Undo snackbar REQUIRES the compensating inverse; partial withdraw is that inverse |
current_tokens stored mirror | No stored balance | Rewhaven invariant: balances are always derived (wallet doc, ledger doc); the POC mirror was a compat shim |
Release → bank bucket | Release → general bucket | general is the exact semantic analog in rewhaven's four-bucket model (RW-08) |
UAT-visible consequence
Pre-existing goals read 0 until deliberately funded; no backfill is possible.
Any UAT goal that previously showed progress from a save balance drops to 0 when the save-fold is removed. No backfill is possible without inventing attribution — a member with 2 goals and one save balance has no correct per-goal assignment. The POC did not backfill either.
Flow tests that seeded save credits to imply goal progress were re-seeded via fundGoal in S3/S4.
POC precedent reference
The POC shipped goal funding on a token-batch substrate
(Chore_app/packages/client_sdk/lib/src/client/client_impl.dart:1864-1955
allocateFromBank, :1738-1750 _rehomeGoalBatches). Rewhaven takes the POC's
semantics (ownership dimension orthogonal to buckets; contributor attribution retained;
release-on-complete-AND-archive; conservation) on rewhaven's substrate (ledger rows, not
batch ownership).