Repository navigation
SPEC 021 Personal Deployment
| Status | Draft |
| PRD source | §8.8 (FR-8.51–8.74) |
| Depends on | SPEC-002, SPEC-003, SPEC-008, SPEC-009, SPEC-016, SPEC-018 |
| Required by | — |
| Milestone | M9 |
AllMyWallet will not be released. It runs on its owner's laptop, managing their own real assets, which both avoids paying for hosting and validates every feature against real data rather than seeds.
The first constraint is what this environment is. It was framed as staging, but staging implies disposable, and this instance holds records that exist nowhere else — manual entries, wallet structure, allocations, fixed-income rates typed in by hand, reconciliation adjustments. B3 keeps the trades; it keeps none of those. So this spec names it personal production: a single-tenant production that happens to run on a laptop, protected like production, with development and tests confined to separate disposable databases. BL-001's own trigger — "before the first non-test user account exists" — fires the day the first real extract is imported.
The second is that no hosting was ever being paid for. No VPS exists; deploy.yml says so itself, and its deploy job is skipped by a preflight check on every run. The only infrastructure spend has been Actions minutes. The existing docker-compose.yml is nonetheless shaped entirely for that absent server — it requires a DOMAIN, pins an https AUTH_URL, publishes Postgres and Caddy on every network interface, and reads the same repo-root .env the development flow uses. None of that can run a loopback instance as written.
The third is that a laptop sleeps. The worker's schedules are fixed-time crons, and SPEC-008's official close capture is irreversible — "the close happens once a day". A lid closed at 18:30 loses that day's close permanently unless the instance recovers it when it next starts.
Three defects found while writing this spec also shape it. The test suite reuses any database named by DATABASE_MIGRATION_URL, and tests/support/reset.ts runs TRUNCATE … CASCADE through it — so the variable the personal instance needs for migrations is exactly the one that would point tests at real data. The published image cannot migrate at all: its runner stage copies no src/, migrate.ts reads a folder that is not there, and tsx is a devDependency the image never installs. And that image is publicly pullable and cannot be made private again, so nothing secret or personal may ever be baked into it.
| Resource | Personal deployment | Why |
|---|---|---|
| PostgreSQL 17 | Mandatory | The ledger, and every derived figure |
web |
Mandatory | Same image as before |
worker |
Mandatory | Quotes, closes, accrual, snapshots, imports, alerts |
| Google OAuth client | Mandatory | Only sign-in method (SPEC-001); accepts an http://localhost redirect |
| Quote provider | Mandatory | Valuation; free tier, internet required |
| BCB SGS, Tesouro Transparente | Mandatory | Index series and Tesouro prices; public endpoints |
| Transactional email provider | Mandatory | SPEC-018 alerts, SPEC-019 goals; free tier |
| Local backup destination | Mandatory | A different volume from the Postgres data |
| Full-disk encryption | Mandatory precondition | The laptop holds real holdings and unstripped extracts before import |
| GHCR image | Mandatory | Kept publishing on merge (DL-021-07) |
| VPS | Skipped | Replaced by this deployment |
| Caddy, TLS, domain | Skipped | Loopback only; nothing to terminate |
| SSH deploy job | Skipped | No target; already skipped by preflight |
| Cloudflare R2 | Skipped | Replaced by local encrypted dumps |
| Sentry | Skipped | Optional in code; unset DSN disables it |
| Uptime Kuma, Prometheus, Grafana, OTel | Skipped | No external observer of a loopback service |
| ID | Rule |
|---|---|
| BR-021-01 | The personal instance is personal production: one tenant, real data, and the only copy of everything B3 does not hold. It is never treated as disposable, rebuildable or safe to reset. |
| BR-021-02 | It is the only environment holding real data. Development and test run against separate, disposable databases (BR-021-06). |
| BR-021-03 | It replaces hosted production. No VPS is provisioned. This amends ARCHITECTURE AR-67 ("production is the only deployed environment") and supersedes BL-003 for as long as this deployment stands. |
| BR-021-04 | The resources marked Mandatory in the inventory above are required to run; each resource marked Skipped stays in the code and is never required. |
| BR-021-05 | The instance serves exactly one person's data. Admitting anyone else's data ends this spec's premise (DL-021-12) and requires SPEC-004's obligations in full before it happens. |
| ID | Rule |
|---|---|
| BR-021-06 | The personal database runs as its own Compose project, on a non-default host port, with its own named volume. It never shares a Postgres instance with development or tests. |
| BR-021-07 | The personal database carries a database-level marker — ALTER DATABASE … SET allmywallet.instance_role = 'personal', read with current_setting('allmywallet.instance_role', true) — written when the instance is initialised. It lives outside every table, so no TRUNCATE can remove it (DL-021-13). |
| BR-021-08 |
tests/support/reset.ts, db:seed, db:seed:reference and the integration and isolation suites read the marker before issuing any statement, and refuse to run against a database carrying it: naming the database, touching nothing, exiting non-zero. The check sits at each tool's own entrypoint — the test database helper's reuse path and the seed scripts' main — and never in the shared database client, which the personal web and worker must be able to use. No individual reset or seed function can bypass an entrypoint check. |
| BR-021-09 | Personal secrets — including DATABASE_MIGRATION_URL — live in an env file outside the repository working tree, never the repo-root .env. The development .env and the personal env file never name the same database. |
| ID | Rule |
|---|---|
| BR-021-10 | Every published port binds to 127.0.0.1. No service is reachable from any network interface, including a shared Wi-Fi. |
| BR-021-11 | Caddy is not run. AUTH_URL is pinned to http://localhost:<port>/api/auth, and DOMAIN is not required. |
| BR-021-12 | The personal definition is a Compose override of the existing file, never a fork. The hosted definition keeps working unchanged, so provisioning a VPS later needs no reconstruction. |
| ID | Rule |
|---|---|
| BR-021-13 | The runtime image carries its own migrator: src/db/migrate.ts bundled to dist/migrate.js exactly as the worker is bundled, with src/db/migrations/ copied into the runner stage. Migrations run as node dist/migrate.js. This also repairs deploy.yml's migration step, which has never executed. |
| BR-021-14 | The image contains no secret and no personal data, ever. It is publicly pullable and cannot be made private again (DL-021-11), so anything baked in is published. |
| BR-021-15 | The image continues to be built on every merge to main and published as both :<sha> and :latest. |
| ID | Rule |
|---|---|
| BR-021-16 | A backup runs before every migration and once per day on the instance's first start that day. |
| BR-021-17 | Each backup is a pg_dump in custom format, encrypted with age before it is written anywhere (AR-60, unchanged). |
| BR-021-18 | The destination is a configured local path. The instance refuses a destination on the same volume as the Postgres data — that copy would die with the disk it exists to protect. |
| BR-021-19 | The most recent backup.retain_count dumps are kept (SPEC-002; default 14). Older dumps are pruned only after a new backup has succeeded. |
| BR-021-20 | A failed backup is never silent: it is logged, reported by /api/health, and shown in the app until a backup succeeds (SPEC-016 BR-016-13). |
| BR-021-21 | The restore procedure is written down and drilled quarterly (SPEC-016 BR-016-09) into a disposable instance, never over the personal database. |
| BR-021-22 | For this deployment, local encrypted dumps supersede BL-001's Cloudflare R2 target. R2 remains the design should hosted production ever be provisioned. |
| ID | Rule |
|---|---|
| BR-021-23 | On start, the instance checks :latest. If its digest differs from the running image, it upgrades before serving. |
| BR-021-24 | The upgrade sequence is fixed: backup → pull → migrate as a gated step → start web and worker → health check against /api/health → record the new last-known-good SHA. |
| BR-021-25 | No migration runs without a backup that succeeded in the same upgrade. A failed backup aborts the upgrade and starts the current image. |
| BR-021-26 | A failed migration leaves the current image running. A migration that succeeds followed by a failed health check restarts the last-known-good image against the new schema — safe only because migrations are expand/contract (AR-69), which this deployment therefore depends on absolutely. |
| BR-021-27 | If the registry is unreachable, the instance starts the current image. Start never blocks on the network. |
| ID | Rule |
|---|---|
| BR-021-28 | On worker start, catch-up runs before cron schedules are registered, covering each business day since the last recorded close capture, up to personal.catchup_max_days (SPEC-002; default 30). The last capture is measured per polled asset, and the window starts at the oldest of them: one asset captured on time never hides another asset's missed days. An asset with no close at all has no absence to measure and does not widen the window. Days an asset already holds a close for are never requested (#169). |
| BR-021-29 | Missed official closes are backfilled from B3's COTAHIST (SPEC-008 BR-008-30), not from the quote provider's history. (Amended 2026-09-28, SPEC-008 DL-008-14.) BCB and Tesouro series already backfill from their latest stored value; that behaviour is relied on, not reimplemented. |
| BR-021-30 | Valuation snapshots and fixed-income accrual are recomputed for backfilled days in date order, so each day's snapshot rests on the one before it. |
| BR-021-31 | A day whose close cannot be recovered is recorded as a gap and marked on charts — never interpolated, and never carried forward as though it were observed. |
| BR-021-32 | Catch-up draws on the same request budget as polling and respects SPEC-008's on-demand reserve and cadence degradation (BR-008-20, BR-008-22). Days the budget cannot cover become gaps (BR-021-31). |
| BR-021-33 | Recovered historical days never trigger opportunity email (SPEC-018). A crossing from last Tuesday is not news; rules evaluate against the latest quote only. |
| ID | Rule |
|---|---|
| BR-021-34 | A transactional email provider on its free tier replaces the log sender for this deployment, as one adapter behind the existing EmailSender port. The provider is selected by configuration; its API key lives in the personal env file (BR-021-09). |
| BR-021-35 | SPEC-018's consent, cooldown, idempotency and content rules apply unchanged. |
| ID | Rule |
|---|---|
| BR-021-36 | Full-disk encryption is a stated precondition. On macOS the setup script checks fdesetup status and refuses to initialise the instance when encryption is off. |
| BR-021-37 | Raw B3 extracts are kept outside the repository working tree before import, consistent with DV-24 and TS-19; the setup documentation says where. |
- With the personal instance running,
pnpm test:integrationpointed at its database refuses before any statement, names the database, and leaves every row intact. -
db:seedanddb:seed:referencerefuse against the personal database in the same way. - Truncating every table in a copy of the personal database leaves the marker readable, and the guards still refuse.
- Development and tests run normally against their own databases with the personal instance up.
- No service in the personal Compose project is reachable from another machine on the same network.
- The instance starts with no
DOMAINset and signs in through Google athttp://localhost. - The hosted
docker-compose.ymlstill validates unchanged without the personal override. -
node dist/migrate.jsapplies every migration from the published image against an empty database. - No file in the published image contains a secret or personal data — verified by inspecting the image layers in CI.
- A merge to
mainpublishes:<sha>and:latest, and the next personal start upgrades to it automatically. - Every upgrade takes a backup before migrating; forcing the backup to fail aborts the upgrade and starts the current image.
- A migration forced to fail leaves the current image running with no schema change applied.
- A failed post-migration health check restarts the last-known-good image, which then serves correctly.
- With the network off, the instance starts on its current image.
- A backup destination on the Postgres data volume is refused.
- Only
backup.retain_countdumps remain after pruning, and pruning never runs after a failed backup. - A failed backup appears in
/api/healthand in the app until the next success. - A restore drill restores the latest dump into a disposable instance with matching row counts.
- Starting the worker after three business days down backfills all three closes and their snapshots, in date order.
- A close the provider cannot supply is shown as a gap on charts, not an interpolated value.
- Catch-up sends no opportunity email for recovered days.
- An opportunity state change on a live quote delivers a real email through the configured provider.
- The setup script refuses to initialise with FileVault off.
- Hosted production — any VPS, cloud host or public URL. BR-021-12 keeps that path open without reconstruction.
- Cloudflare R2 and off-site backups. Local encrypted dumps only (DL-021-02); a machine and backup drive lost together lose the data.
- Remote access — home network, tailnet or the internet (DL-021-06).
- More than one person's data (BR-021-05).
- Sentry, Uptime Kuma and the metrics stack. They stay in the code, unused.
- Making the GHCR package private — not possible once public (DL-021-11).
- Automatic restore. Restores are deliberate and manual.
- Choosing a specific email provider. The adapter is selected by configuration.
| # | Decision | Alternatives considered | Rationale |
|---|---|---|---|
| DL-021-01 | Named personal production, not staging. | Staging with a protected data tier; true staging with disposable data | Staging invites the one mistake that cannot be undone. Everything B3 does not hold — manual entries, wallets, allocations, typed rates, adjustments — would be lost on the first disk failure or bad migration. Naming it for what it is makes the protections read as obvious rather than as exceptions. |
| DL-021-02 | Encrypted dumps to a local disk. | Cloudflare R2 as ARCHITECTURE §15 decided; relying on Time Machine | Chosen for simplicity: no cloud account and no credentials. The cost is recorded: the laptop and the backup drive lost or stolen together lose everything. Time Machine was rejected because copying a live Postgres data directory mid-write can produce an unrestorable backup that nothing reports. Dropping R2 also removes the LGPD Art. 33 international-transfer consequence §15 accepted. |
| DL-021-03 | Run on the laptop, with catch-up on start. | An always-on machine at home; a small VPS after all | Zero cost and no second device. Catch-up is real work because close capture is irreversible, and intraday alerts only fire while the laptop is awake. A VPS contradicts the reason for this spec. |
| DL-021-04 | A real email provider on its free tier. | A local mail catcher; stay log-only | SPEC-018 exists to tell the user about a crossing they were not watching for. A catcher or a log still requires going and looking, which is the problem the feature solves. One person's volume sits far inside any free tier. |
| DL-021-05 | Separate instance plus a refusal guard. | Separate instance alone; same server with a different database name | The failure is silent and total: one leftover DATABASE_MIGRATION_URL in a shell and reset.ts truncates a real portfolio. The test suite's fallback already targets localhost:5432. Two independent barriers mean one wrong variable cannot erase the data. |
| DL-021-06 | Loopback only. | Home-network access; a private tailnet | Postgres and Caddy currently publish on every interface, which on a shared Wi-Fi exposes real holdings behind a single password. Loopback needs no certificate, no hostname and no second trust boundary. |
| DL-021-07 | Keep publishing the image on merge. | Build locally and retire deploy.yml; run from source with no image |
Keeps a ready artifact and the laptop never compiles. The cost is recorded: roughly 9 billable minutes per merge for an image with no hosted target. |
| DL-021-08 | Back up before every migration and daily on start. | Weekly, as BR-016-09 says; before migrations only | A laptop is off unpredictably, so "weekly" silently becomes "whenever it is next on at the scheduled time". The pre-migration dump covers the riskiest moment; the daily one covers disk failure and bad imports between migrations. |
| DL-021-09 | The migrator ships in the image. | Migrate from the source checkout; a separate migrator image | Keeps the image the single unit, so the schema applied always matches the code running. Migrating from a checkout lets the two disagree undetectably; a second image doubles CI build cost. It also repairs deploy.yml's dormant step. |
| DL-021-10 | Upgrade automatically on start. | An explicit upgrade command; a pinned tag bumped by hand | Always current with no chore. The cost is recorded deliberately: any merge to main migrates the real database the next time the laptop opens, with no moment to read what changed. BR-021-24–27 bound that risk — mandatory backup, gated migration, health-checked rollback — and make AR-69's expand/contract discipline an absolute dependency rather than a preference. |
| DL-021-11 | The image stays public. | Make the package private; delete it and republish privately | GitHub's documentation states that a public package cannot be made private again, and deleting it destroys every version. The repository is public — a requirement for the wiki — so a public image exposes no source that is not already public. BR-021-14 enforces the one invariant that matters: nothing secret or personal is ever baked in. |
| DL-021-12 | One person's data only. | Allow family or shared use on the same instance | LGPD does not apply to processing by a natural person for exclusively private, non-economic purposes (Art. 4, I), which is what makes SPEC-004's third-party obligations — privacy policy, subprocessor inventory — unnecessary here. That premise ends the moment anyone else's data enters, so the boundary is written as a rule rather than assumed. |
| DL-021-13 | The marker lives at the database level, not in a table. | A runtime_state row, as first drafted |
tests/support/reset.ts truncates runtime_state itself — it is in CONFIG_TABLES — so a table marker is erased by the very operation it exists to stop. One unguarded code path would destroy the evidence every later check relies on, silently. A per-database setting survives TRUNCATE, needs no migration, and is readable from any connection to that database. |
| DL-021-14 | Catch-up measures the missed window per asset (amends BR-021-28, #169). | The newest close across the polling set, as first built; a one-off back-fill command only | The newest close let one captured asset stand in for all of them: while brapi answered only its test tickers, PETR4's daily close made every start report nothing missed as 60 assets went eight trading days without a close, and no gap was ever recorded. Per asset, the window costs nothing extra for assets already captured, because only days with no close are requested. A one-off command would have repaired that week and left the blind spot for the next partial outage. |