goldberry

All docs ↓

Goldberry — Design Plan

Oct 8, 2026 · Jeffrey Chin

Snapshot of the living plan doc: https://claude.ai/artifact/M1fZn8vas1tyyRnjCRNZjx (exported 2026-10-08, rev 55). The doc is the source of truth; refresh this copy when it changes.

1. Summary

A self-hosted web app for tracking kids' allowances, shipped as one open-source, multi-arch container image. Parents (admins) add and remove money with optional comments. Kids sign in with a username and PIN, see their balance, and request withdrawals, which show up as notifications for parents (in the app, and by email when SMTP is set up).

Three things drive every decision below:

  1. One container, one volume. A Go binary serves server-rendered HTML (HTMX). The only state is a database: SQLite on a mounted volume by default, or Postgres if DATABASE_URL points at one.
  2. Money is an append-only ledger. A balance is never stored and edited in place; it is the sum of ledger entries. A correction is a reversing entry, so the history can always be audited.
  3. Kids are the realistic adversary. A 4-digit PIN has 10,000 combinations, and the home LAN is where the curious 10-year-old sits. Per-account throttling is required, not optional.

In scope: admin and kid accounts, credits and debits, withdrawal requests with holds, in-app and email notifications, Spend/Save/Give jars, recurring allowance, savings goals, and parent-paid interest. Named for Goldberry, the River-daughter in The Lord of the Rings.

2. Design principles

Principle Consequence
One image, one stateful thing A single goldberry container plus a volume (or an external Postgres). No Redis, no queue, no sidecars.
Money is integers Every amount is int64 minor units (cents). Floats never touch money.
The ledger is append-only Balances are a SUM. Edits and deletes are reversing entries.
Available ≠ balance Pending withdrawal requests hold funds. Kids see both numbers.
Identity is local and minimal Kids have a display name, username and PIN only: no email, no birthdate.
The parent is the recovery path Kids never reset themselves. A locked kid is unlocked by a parent.
Email is optional, never required Every notification exists in the app first. SMTP only delivers a copy.
Storage is a seam, not a fork One Store interface and one contract test suite, run against SQLite and Postgres in CI.
Defer every optional subsystem Ship the seam (web push, OIDC) and leave the implementation for later. The JSON API and MCP server have since been built (ADR 0015, 0016).

3. Architecture

flowchart TB
  %% One container and one volume; Postgres and SMTP are optional (dashed)
  parent["Parent's phone<br/><small>admin: username + password</small>"]
  kid["Kid's tablet<br/><small>kid: username + PIN</small>"]
  proxy["Reverse proxy<br/><small>TLS termination: Traefik, Caddy or a Gateway</small>"]
  subgraph container["goldberry container · one Go binary"]
    web["Web handlers<br/><small>HTMX pages, auth, CSRF</small>"]
    jobs["Background jobs<br/><small>schedules, interest, outbox</small>"]
    svc["Service layer<br/><small>every money rule: ledger, holds, goals, interest</small>"]
    store["Store interface<br/><small>one contract suite, run against both drivers</small>"]
  end
  sqlite[("SQLite (default)<br/><small>one file on the /data volume</small>")]
  pg[("Postgres (optional)<br/><small>set GOLDBERRY_DATABASE_URL</small>")]
  smtp["SMTP relay (optional)<br/><small>Gmail with an app password</small>"]
  inbox["Parents' inboxes<br/><small>a copy of each alert</small>"]
  parent --> proxy
  kid --> proxy
  proxy --> web
  web --> svc
  jobs --> svc
  svc --> store
  store --> sqlite
  store -.-> pg
  jobs -.-> smtp
  smtp -.-> inbox
  classDef optional stroke-dasharray: 5 4
  class pg,smtp,inbox optional

A Mermaid diagram; see it drawn on GitHub.

Both kinds of user reach the same container through the reverse proxy. Only the Store talks to the database, and only the background jobs talk to SMTP.

3.1 Deliberately excluded

4. Accounts & identity

There are two roles: admin (parent) and kid. Each install is a single household, so there is no tenancy column (see §15).

Threat model. There are three adversaries:

Nothing here defends against a hostile network operator. TLS at the reverse proxy covers the wire (§12).

4.1 Credentials

Admin Kid
Identifier Username Username
Secret Password, minimum 10 characters PIN of 4–6 digits (household setting, default 4)
Hash argon2id argon2id. Hashing still matters: a stolen DB file must not reveal PINs reused on a phone.
Backoff Exponential after 3 failures; never hard-locks Exponential after 3 failures; hard lock after 10
Recovery Another admin resets it, or the CLI: docker exec … goldberry admin reset-password <user> An admin resets the PIN or unlocks the account. Never self-service, never on a timer.
Session length 7 days, sliding 30 days on a remembered device, otherwise 12 h

4.2 Sessions and CSRF

4.3 Authorization rules

4.4 First-run setup

5. Money model

Each kid has jars (Spend / Save / Give by default). Each jar's balance is the sum of its ledger entries. Available is the balance minus pending withdrawal holds.

balance(jar)   = SUM(ledger_entries.amount WHERE jar_id = jar)
held(jar)      = SUM(withdrawal_requests.amount WHERE jar_id = jar AND status = 'pending')
available(jar) = balance(jar) - held(jar)

5.1 Rules

5.2 Jars

5.3 Concurrency

Race to prevent: a kid double-taps "Request $20" with $25 available, two requests both pass the check, and $40 is held against $25.

5.4 Jar locks

A lock restricts money leaving a jar. Who set the lock decides whether the kid can get past it.

Admin lock Self-lock (set by the kid)
Strength Hard. The kid can't override or remove it. Soft. The kid can override or remove it, after the gauntlet below.
Typical use "Give jar only goes out with a parent" "Don't let me touch Save until the Switch"
Shown to the kid as "Locked by a parent" (+ until date, note) "You locked this on Mar 3: Saving for a Switch"
Removed by Any admin The kid (gauntlet) or any admin (no gauntlet)

The override gauntlet (self-locks only). It is meant to be annoying but kind. The kid can still go ahead; they just have to mean it.

  1. A full-screen warning in loud colors repeats the kid's own reason and date, and shows the cost: "Your Switch goal drops from 64% to 22%."
  2. A 10-second countdown before the button enables, with escalating copy ("Are you sure?" → "Past-you set this for a reason" → "Okay, okay").
  3. Press and hold for 3 seconds on "Break my lock". A tap is not enough.
  4. Choose: override once (this transfer or request only, and the lock stays) or remove the lock.

The shake animation and countdown respect prefers-reduced-motion; the countdown stays and the motion goes. Every override writes a lock_overrides row, which shows in the kid's activity for admins. Whether admins get a notification is the per-admin preference Kid overrode own lock (default: in-app only, no email).

6. Withdrawal requests

flowchart LR
  %% Funds are held from request until a parent decides
  start["Kid requests<br/><small>amount, jar, reason</small>"] --> check{"≤ available?"}
  check -- no --> refused["Form error<br/><small>nothing held</small>"]
  check -- yes --> pending["Pending<br/><small>hold placed · admins notified</small>"]
  pending --> approved["Approved by a parent<br/><small>posts one withdrawal ledger entry</small>"]
  pending --> denied["Denied by a parent<br/><small>hold released; note shown to kid</small>"]
  pending --> cancelled["Cancelled by the kid<br/><small>hold released; admins notified</small>"]
  pending --> expired["Expired (admin-set window)<br/><small>hold released; everyone notified</small>"]

A Mermaid diagram; see it drawn on GitHub.

A request reserves the money as soon as it's made. Only approval moves it, and every other outcome gives it back.

7. Features

7.1 Add and remove funds (admin)

7.2 Recurring allowance

7.3 Savings goals

7.4 Parent-paid interest

7.5 Balance graph

7.6 Investing

Kids buy and sell real US stocks and funds (ETFs) at real prices, with the money in a fourth jar. ADR 0014 records the model; docs/operations/investing.md covers setup.

8. Notifications

Every notification is first a row in notifications, one per recipient. Email is an optional second channel that delivers a copy. If SMTP is down or never configured, nothing is lost.

8.1 Events

Event Admins Kid Email by default
Withdrawal requested ✓ Yes
Request approved / denied ✓ No (kids have no email)
Request cancelled by kid ✓ No
Funds added / removed ✓ No
Goal reached ✓ ✓ Yes
Kid account locked (10 failed PINs) ✓ Yes
Recurring allowance or interest posted ✓ No (weekly digest optional)
SMTP send failing ✓ n/a (in-app banner)
Kid overrode or removed own jar lock ✓ No
A waiting order filled at the open ✓ No
A sale made a profit (earnings owed) ✓ No
Earnings paid ✓ No

Each admin chooses per event type: in-app only, or in-app plus email.

8.2 In-app delivery

8.3 Email (SMTP)

8.4 Later, not now

9. Storage

SQLite on a volume by default; Postgres when GOLDBERRY_DATABASE_URL says so. One household writes a few dozen rows a week, which SQLite handles without noticing. Postgres is there for people who already run one (or CloudNativePG on K3s) and want it in their existing backups.

SQLite (default) Postgres (optional)
URL sqlite:///data/goldberry.db postgres://user:pass@host:5432/goldberry?sslmode=…
Driver modernc.org/sqlite: pure Go, so CGO_ENABLED=0, a static binary and painless arm64 jackc/pgx/v5 via database/sql
Replicas Exactly 1. The K8s Deployment uses the Recreate strategy 1 by default. More work only because of idempotency keys and advisory locks
Write serialization BEGIN IMMEDIATE plus a single-connection write pool SELECT … FOR UPDATE on the kid row
Backup Built-in VACUUM INTO snapshots (§12) Your existing pg_dump / CNPG backups

SQLite connection pragmas: journal_mode=WAL, busy_timeout=5000, foreign_keys=ON, synchronous=NORMAL. Use two pools: a read pool with N connections and a write pool with one.

Never put the SQLite file on NFS or SMB. WAL relies on shared memory and byte-range locks that network filesystems break, which silently corrupts data. On K3s use a block-backed RWO volume (local-path, Longhorn, Ceph RBD). The docs and the startup log both warn when /data is a network filesystem (detected via statfs).

9.1 How the pluggable store works

10. Data model

The types are shown Postgres-style; the SQLite migration uses the equivalents from §9.1. All IDs are UUIDv7 TEXT and all amounts are BIGINT minor units.

-- Household (exactly one row)
household(id, name, currency CHAR(3), timezone,
          pin_length, allow_negative, request_expiry_days, kid_picker_login,
          created_at)

-- Identity
users(id, role,                         -- admin | kid
      username UNIQUE, display_name, avatar,
      password_hash,                    -- admins only (argon2id)
      pin_hash,                         -- kids only (argon2id)
      email,                            -- admins only, optional
      disabled_at, created_at)

sessions(id_hash PK, user_id, device_label, remember,
         created_at, last_seen_at, expires_at, revoked_at)

auth_attempts(user_id PK, failures, window_start, next_allowed_at, locked_at)

api_tokens(id PK, token_hash UNIQUE, user_id, label, scopes,
           created_at, last_used_at, revoked_at)   -- ADR 0015; SHA-256 only, owned by an admin

-- Money
jars(id, kid_id, name, kind,           -- spend | save | give | invest | custom
     sort_order, archived_at)

split_rules(kid_id, jar_id, basis_points)   -- sums to 10000 per kid
  PRIMARY KEY (kid_id, jar_id)

ledger_entries(                          -- APPEND-ONLY: no UPDATE, no DELETE
  id, kid_id, jar_id,
  amount BIGINT,                         -- signed: + credit, - debit
  kind,                                  -- deposit | withdrawal | adjustment | allowance
                                         -- | interest | transfer_in | transfer_out | reversal
                                         -- | invest_buy | invest_sell | invest_gain (§7.6)
  comment, private_note,
  actor_id,                              -- user who caused it; NULL = system (scheduler)
  reverses_id,                           -- set on kind = reversal
  transfer_id, batch_id,                 -- groups pairs / bulk posts
  request_id,                            -- set when it settles a withdrawal request
  schedule_id,
  idempotency_key UNIQUE,
  effective_at,                          -- business date (catch-up keeps the original date)
  created_at)
  INDEX (jar_id, effective_at), INDEX (kid_id, created_at)

withdrawal_requests(
  id, kid_id, jar_id, amount BIGINT, reason, goal_id,
  status,                                -- pending | approved | denied | cancelled | expired
  decided_by, decision_note, decided_at,
  idempotency_key UNIQUE,
  created_at, expires_at)
  INDEX (jar_id) WHERE status = 'pending'

schedules(id, kid_id, amount, cadence,  -- weekly | biweekly | monthly
          weekday, day_of_month, anchor_date,
          target_jar_id,                 -- NULL = use split rule
          comment_template, active, last_occurrence, created_by, created_at)

interest_rules(kid_id, jar_id, monthly_bps, monthly_cap, active)
  PRIMARY KEY (kid_id, jar_id)

goals(id, kid_id, jar_id, name, emoji, target_amount, priority,
      created_by, reached_at, archived_at, created_at)

-- Jar locks (§5.4)
jar_locks(id, jar_id,
          to_jar_id,                     -- NULL = every route out + withdrawal requests
          set_by_role,                   -- admin (hard) | kid (soft)
          set_by, reason, until_date, created_at, removed_by, removed_at)

lock_overrides(id, lock_id, kid_id, action,  -- once | removed
               ledger_entry_id, request_id, created_at)

-- Investing (§7.6). Prices are micro-dollars; shares are micro-shares.
securities(symbol PK, name, kind, updated_at)          -- kind: stock | fund
quotes(symbol PK, price, open, prev_close, quoted_at, fetched_at)   -- a cache; not exported
orders(id, kid_id, jar_id, symbol, side,               -- buy | sell
       amount,                                         -- buy: cents
       shares,                                         -- micro-shares (a sell's, or a buy's once filled)
       status,                                         -- queued | filled | cancelled
       price, proceeds, cost_basis, ledger_entry_id, placed_by,
       idempotency_key UNIQUE, created_at, filled_at, cancelled_at)
payouts(id, kid_id, jar_id, order_id UNIQUE, amount,   -- a sale's profit
        status, paid_by, paid_at, ledger_entry_id, created_at)   -- pending | paid
portfolio_snapshots(kid_id, day, value, cost) PRIMARY KEY (kid_id, day)

-- Notifications
notifications(id, recipient_id, kind, payload JSON,
              request_id, read_at, created_at)
  INDEX (recipient_id, read_at)

notification_prefs(user_id, kind, email BOOLEAN)
  PRIMARY KEY (user_id, kind)

email_outbox(id, notification_id, to_addr, subject, body_text, body_html,
             attempts, next_attempt_at, last_error, sent_at, failed_at)

settings(key PK, value, secret BOOLEAN)  -- SMTP config; secret values AES-GCM encrypted

-- Audit (non-money admin actions: PIN reset, unlock, user added, settings changed)
audit_log(id, actor_id, action, target_id, detail JSON, created_at)

Why effective_at and created_at both exist: catch-up allowance and back-dated entries ("forgot to log last Saturday's chores") need a business date for the kid's ledger. created_at is the immutable audit time. Interest and the ledger view use effective_at.

Why holds aren't ledger entries: a hold isn't money moving. Keeping holds as withdrawal_requests.status = 'pending' means approval posts exactly one ledger entry, and denial posts none.

11. Routes & UI

The app is server-rendered with Go html/template, and HTMX swaps fragments. HTMX and the CSS are vendored into the binary (embed.FS), not pulled from a CDN, so the app works on a LAN with no internet. There is no JS build step.

11.1 Routes

Route Who Purpose
GET /setup, POST /setup First run only Household and first admin; needs the setup token (§4.4)
GET /login, POST /login Anyone Username, then a password or a PIN pad depending on the account's role
GET /login/kids Anyone Optional kid-picker tiles (household setting)
POST /logout Signed in Revoke the session
GET / Kid Home: jars, available vs pending, goals, recent activity
GET /ledger Kid Own history, filter by jar
GET /invest, GET /invest/find, GET /invest/stocks/:symbol Kid Investing: portfolio, search, one stock or fund (§7.6)
POST /invest/orders, POST /invest/orders/:id/cancel Kid Buy or sell; cancel a waiting order
GET /invest/learn Signed in Investing words and habits, for kids
GET /graph Kid Own balance graph: ?range=1w|1m|3m or ?from=&to=, &show=total|jars|<jar id> (§7.5)
POST /requests, POST /requests/:id/cancel Kid Create or cancel a withdrawal request
POST /goals, POST /goals/:id Kid Create or edit own goals
POST /transfers Kid Move between own jars, subject to jar locks (§5.4)
POST /jars/:id/locks, POST /locks/:id/{override,remove} Kid Set a self-lock; override or remove one after the gauntlet (§5.4)
GET /admin Admin Dashboard: a card per kid plus the pending-requests queue
GET /admin/kids/:id Admin One kid: jars, locks, ledger, schedules, interest, goals
GET /admin/kids/:id/graph Admin One kid's balance graph (§7.5)
GET /admin/kids/:id/invest Admin One kid's investments, read-only
POST /admin/payouts/:id/pay, POST /admin/orders/:id/cancel Admin Pay investing earnings; cancel a waiting order
POST /admin/settings/investing Admin Turn investing on or off
POST /admin/kids/:id/entries Admin Add or remove funds
POST /admin/entries/:id/reverse Admin Reverse a ledger entry
POST /admin/requests/:id/{approve,deny} Admin Decide a request, with an optional note
/admin/users/* Admin Add a kid or admin, reset a PIN or password, unlock, disable, revoke sessions
/admin/settings/* Admin Household, jars and split rules, schedules, interest, email and SMTP test, export
GET /notifications, GET /notifications/count, POST /notifications/read Signed in The bell
/api/v1/* Bearer token The JSON API: reads, jar/goal edits, entries, batches, reversals, transfers, request decisions. Spec at GET /api/v1/openapi.json
POST /mcp Bearer token MCP server (opt-in, GOLDBERRY_MCP_ENABLED); tools filtered by scope
GET /healthz, GET /readyz, GET /metrics Unauthenticated Liveness, DB readiness, Prometheus

The kid routes sit at the root because a kid's home screen is the product; admin routes sit under /admin. Middleware enforces the role on every route group.

JSON API. Handlers call a service layer, never the store directly, so /api/v1 (for Home Assistant, a phone widget, scripts and AI harnesses) is a sibling of the web handlers with no refactor. Bearer tokens with read / write / money scopes: ADR 0015. An opt-in MCP server at /mcp sits on the same layer: ADR 0016.

11.2 UI notes

12. Deployment

12.1 The image

12.2 Configuration

Configuration is env vars only, and a /data/config.env file is optional. Every variable has a sane default except the secret key.

Variable Default Notes
GOLDBERRY_DATABASE_URL sqlite:///data/goldberry.db Or postgres://…
GOLDBERRY_SECRET_KEY (none) 32+ random bytes, base64 (or GOLDBERRY_SECRET_KEY_FILE). Encrypts SMTP secrets. Without it the app still runs but refuses to store SMTP passwords.
GOLDBERRY_BASE_URL (none) Public URL for email links, e.g. https://goldberry.home.example.com
GOLDBERRY_LISTEN :8080
GOLDBERRY_TRUSTED_PROXIES (none) CIDRs whose X-Forwarded-* headers are trusted
GOLDBERRY_INSECURE_COOKIES false Drops the cookie Secure flag for plain-HTTP testing. Logs a warning on every start.
GOLDBERRY_MCP_ENABLED false Serve the MCP server at /mcp (ADR 0016). The JSON API is always on; both need a token.
GOLDBERRY_BACKUP_SCHEDULE / _RETAIN 03:15 / 14 SQLite snapshots (§12.5)
GOLDBERRY_SMTP_HOST, _PORT, _SECURITY, _USERNAME, _PASSWORD, _FROM (none) Override and lock the UI's email settings. _PASSWORD_FILE is also accepted, for Docker and K8s secrets.
GOLDBERRY_LOG_FORMAT json json or text (log/slog)
GOLDBERRY_FINNHUB_API_KEY (none) Market prices for investing (§7.6). Investing stays off without it. _FILE works too.
TZ UTC Default for the household timezone at setup

12.3 Docker Compose

services:
  goldberry:
    image: ghcr.io/chinny-labs/goldberry:1
    restart: unless-stopped
    ports: ["8080:8080"]          # or put it behind Caddy/Traefik for TLS
    environment:
      GOLDBERRY_SECRET_KEY_FILE: /run/secrets/goldberry_key
      GOLDBERRY_BASE_URL: https://goldberry.home.example.com
      TZ: America/New_York
    volumes:
      - ./data:/data
    secrets: [goldberry_key]
secrets:
  goldberry_key:
    file: ./goldberry_key.txt     # openssl rand -base64 32 > goldberry_key.txt

12.4 Kubernetes (K3s)

12.5 TLS and backups

13. Repository & build

One public AGPL-3.0 repo, chinny-labs/goldberry, with the Go module, migrations, deploy files and docs together. Docs change in the same PR as the code. AGPL §13 applies because users reach the app over a network, so every page footer links to the source of the running version: the repo URL plus the commit, baked in at build time.

13.1 Stack

13.2 Layout

goldberry/
├── cmd/goldberry/            # serve | migrate | backup | export | import | admin | healthcheck
├── internal/
│   ├── config/               # env parsing, _FILE secrets
│   ├── money/                # minor units, parse/format, split with remainder
│   ├── auth/                 # argon2id, sessions, throttling, CSRF
│   ├── service/              # ledger, requests, goals, schedules, interest (domain rules)
│   ├── api/                  # JSON API (/api/v1) and MCP server (/mcp); calls service only
│   ├── scheduler/            # in-process ticker: allowance, interest, request expiry, backups
│   ├── notify/               # notifications, Notifier interface, smtp, outbox worker
│   ├── store/                # Store interface
│   │   ├── sqlite/
│   │   ├── postgres/
│   │   └── storetest/        # contract suite, run against both
│   └── web/                  # handlers, middleware, templates/, static/
├── migrations/{sqlite,postgres}/
├── deploy/{compose,helm/goldberry}/
├── e2e/                      # Playwright smoke tests against the built image
├── docs/{adr,operations}/
├── .ko.yaml  Dockerfile  justfile
└── README.md                 # 60-second Compose quickstart first

13.3 CI (GitHub Actions)

  1. golangci-lint, go vet, gofmt check.
  2. go test -race ./... with the store contract suite in a matrix: SQLite and Postgres (service container).
  3. Migration parity: migrations/sqlite and migrations/postgres must list the same version numbers.
  4. Build the image with ko, start it, run the e2e smoke test: setup → add kid → deposit → kid login → request → admin approve → balances correct. Then backup → restore → balances match.
  5. helm lint and a kind or k3d install smoke test.
  6. On a tag: multi-arch push to GHCR, cosign sign, SBOM, chart pushed as OCI, and a GitHub Release with notes.

Versioning is SemVer via conventional commits and release-please. Renovate keeps Go modules, HTMX and Actions current.

13.4 Decision records

As in Copperkeep, use docs/adr/. Seed it with these:

# Decision
0001 One container; Go + HTMX, no SPA
0002 SQLite by default, Postgres optional, behind one Store and a shared contract suite
0003 Money is an append-only ledger in integer minor units
0004 Withdrawal requests hold funds on creation
0005 In-process scheduler with idempotency keys, no cron sidecar
0006 Migrations run at startup (departs from Copperkeep, and why)
0007 Email via an outbox; in-app is the source of truth
0008 Per-account throttling; kids hard-lock, admins never do
0009 Jar locks: admin locks are hard, kid self-locks are soft with an override gauntlet
0010 AGPL-3.0 licence, matching Copperkeep
0013 Charts are SVG drawn on the server
0014 Investing is a simulated brokerage at real prices, with parent-paid profit

14. Phasing

Each phase ends with something the family can use. Phase 1 alone replaces a spreadsheet.

  1. Phase 0: Skeleton (an evening). Repo, go mod, /healthz, config loading, ko multi-arch build, CI matrix with an empty store contract suite on SQLite and Postgres, and a Compose file. Exit: docker compose up serves /healthz on amd64 and arm64.
  2. Phase 1: Walking skeleton. First-run setup with the token, admin and kid login with throttling, sessions and CSRF, one jar per kid, add and remove funds with comments, reversals, kid home and ledger, and the money package with tests. Exit: a kid signs in on the family tablet and sees a deposit a parent made from their phone.
  3. Phase 2: Requests & notifications. Withdrawal requests with holds, approve, deny, cancel and expiry, the in-app bell with inline approve/deny, the idempotency keys, and the double-submit race test. Exit: request → notification → approve in under 10 seconds, end to end.
  4. Phase 3: Email. SMTP settings and env override, encrypted secret, Gmail preset, test button, outbox worker with retries, and per-admin preferences. Exit: a request emails both parents through Gmail with an app password.
  5. Phase 4: Jars & recurring allowance. Configurable jars, split rules with remainder handling, transfers, schedules with catch-up, and pause. Exit: Saturday's allowance posts split 70/20/10 with no one touching it, including after a reboot on Friday night.
  6. Phase 5: Goals & interest. Goals waterfall, goal-reached notifications, "Ask to buy it", monthly interest on average daily balance with a cap, and the 12-month projection.
  7. Phase 6: v1.0 release shape. Helm chart and OCI publish, built-in backups and tested restore, export/import, PWA manifest, cosign/SBOM, docs (install, upgrade, backup-restore, troubleshooting), and README screenshots.
  8. Later. Web push, a Home Assistant integration (the /api/v1 JSON API and MCP server now exist), an ntfy/webhook notifier, OIDC for admins (Authentik/Pocket ID), parent-matching on goals, i18n, and chore tracking ("earn by task").

15. Risks, decisions & open questions

15.1 Open risks

Risk Mitigation
SQLite on an NFS-backed PVC corrupts the database statfs check with a loud startup warning; docs and chart notes say RWO block storage only
A kid brute-forces a sibling's PIN Per-account backoff, hard lock at 10 failures, admin notification, parent-only unlock
SQLite and Postgres behavior drifts apart Shared contract suite in a CI matrix; migration parity check
Scheduler double-posts allowance or interest Unique idempotency keys per occurrence; a duplicate run is a no-op
DST or timezone bugs in schedules and interest All business dates in the household TZ; table tests across DST transitions
Gmail tightens app passwords or SMTP Any SMTP relay works; Notifier interface; web push planned
GOLDBERRY_SECRET_KEY lost or rotated Only the SMTP password is encrypted with it: re-enter it. The app warns when decryption fails.
Single-disk data loss Nightly VACUUM INTO snapshots, off-box copy documented, restore tested in CI
Kid disputes the interest math Each interest entry's comment shows the average balance, the rate and the days

15.2 Decisions log

Decision Rationale
Go + HTMX, server-rendered Static binary, tiny image, trivial arm64, no JS toolchain
SQLite default, Postgres optional Zero ops for a family; reuse existing Postgres when it's there
Hand-written portable SQL, not sqlc or an ORM sqlc generates per-engine packages; an ORM hides the money-critical SQL
modernc.org/sqlite (no CGO) Static distroless image, painless cross-compile
Append-only ledger, integer cents Auditable history, reversals instead of edits, no float bugs
Holds on request Kids can't over-request across several requests; available is always honest
Holds are request rows, not ledger entries Approve = one ledger entry; deny = none
Balances computed, not cached A few thousand rows; a cache is a consistency bug waiting to happen
Single household per install No tenancy column; one family = one container
PIN for kids, hashed with argon2id The DB file must not leak PINs reused elsewhere
Kids hard-lock; admins only back off A parent can unlock a kid; nobody can unlock the last admin except the CLI
SameSite=Lax plus a CSRF token Email deep links must land signed in
In-app first, email via outbox SMTP failure never loses a notification or blocks a request
In-process scheduler One replica; idempotency keys make catch-up and duplicates safe
Migrations at startup One binary, one replica; advisory lock on Postgres
Interest on average daily balance Removes end-of-month deposit gaming
Goals are views over a jar Money never gets stranded inside a goal
No Redis, IdP or SPA in v1; JSON API and MCP added in 1.2 Defer every optional subsystem; ship the seams (the API arrived on that seam with no refactor)
Kids move money freely; locks restrict Autonomy by default; restriction is an explicit, visible choice
Self-locks are soft, admin locks are hard Kids practise commitment without being trapped by it; parents keep authority
Approve = handed over, not tracked separately The app records decisions; it can't see cash change hands
AGPL-3.0, in chinny-labs Modified hosted versions must share their source; matches Copperkeep

15.3 Open questions

View this page on GitHub