Skip to main content

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.

DimensionValue
Envelope balanceΣ delta where goal_id = X (derived, never stored)
Per-contributor netSame fold grouped by member_id
bucket on an envelope legSource bucket for fund legs; destination bucket for withdraw legs; release legs stamp general (audit trail)
Schema changeAdditive only: alter table ledger_entries add column goal_id uuid references goals(id) on delete cascade + partial index

Rejected alternatives

AlternativeWhy not
Per-goal Bucket valuesUnbounded bucket vocabulary; CHECK churn per goal; walletOf switch breaks
goal_funds side tableSecond token truth; conservation/zero-floor/undo machinery needs twins outside the ledger
POC token-batch ownershipRewhaven'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 fundGoalundo-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 throws DomainRuleException)
  • 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 NULL branch enforces existing member+bucket floor; goal IS NOT NULL branch 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 — debit goal_id leg + credit general leg (kind move, shared refId per contributor)
  • Release is idempotent — releasing an empty envelope is a no-op
  • restoreGoal after 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 behaviourRewhaven choiceRationale
Source = bank only (allocateFromBank)Any of the four bucketsRewhaven move UX is already any-bucket → any-bucket; goals are one more deliberate destination
No user-facing partial withdrawReal withdrawFromGoal verbUndo snackbar REQUIRES the compensating inverse; partial withdraw is that inverse
current_tokens stored mirrorNo stored balanceRewhaven invariant: balances are always derived (wallet doc, ledger doc); the POC mirror was a compat shim
Release → bank bucketRelease → general bucketgeneral 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).