Imported from sergeyfarin/rekenraam (
.claude/skills/validate-and-ship/SKILL.md). Install upstream withnpx skills add sergeyfarin/rekenraam --skill validate-and-ship. Copyright stays with the author.
Validate And Ship
Run the app
pnpm install # once, repo root
pnpm dev # backend :16888 + frontend :1888 (proxies /api)
pnpm dev:backend # go run, APP_ENV=development
pnpm dev:frontend
Open http://localhost:1888. Dev SQLite lives at backend/var/dev.sqlite
(git-ignored). Key env vars: HTTP_ADDR, DATABASE_URL (file:...),
APP_ENV (development|production, defaults production),
REKENRAAM_SECRET_KEY (base64 32 bytes; required for import connections),
TRUST_PROXY_HEADERS + TRUSTED_PROXY_CIDRS, OPEN_EXCHANGE_RATES_APP_ID.
Owner password reset: recover-owner command (see
docs/developer-workflow.md § Local Owner Recovery) — it backs up and revokes
sessions; never edit the users table.
Validation matrix (narrowest first — this is the contract; CI runs the same scripts)
| Changed | Run |
|---|---|
backend/** |
./scripts/test-backend.sh (= go test -race -p 1 -timeout=15m ./...), plus go vet ./..., gofmt -l . in backend/ |
| Backend coverage check (optional, same script) | COVERAGE=1 ./scripts/test-backend.sh — non-race coverage pass, prints the merged total; CI enforces a soft floor (scripts/check-coverage-floor.sh) |
frontend/** |
./scripts/test-frontend.sh (openapi:generate + paraglide:compile + svelte-check); pnpm --dir frontend run test for unit-tested logic |
| OpenAPI | both scripts above |
| Integrated shape / static serving / embed | pnpm build (builds frontend, copies into backend/internal/web/dist/, runs embed test, compiles dist/rekenraam) |
| User journeys | ./scripts/test-e2e.sh (self-contained: builds app, boots on 127.0.0.1:16889, fresh backend/var/e2e.sqlite) |
Never invent parallel validation commands; if a command must change, change
the script and CI together and update docs/developer-workflow.md.
Review checklist — this repo's recurring bug classes
Every one of these shipped as a real bug here at least once. Check them on any non-trivial diff (yours or reviewed):
-
Silent limit clamping — internal full-set reads using a paginated repo method whose limit gets clamped (import commit processed only 200 of 201 rows). Internal reads use explicit
ListAll*methods. -
PATCH omission overwrites — optional update fields must be pointer types end-to-end; a plain
boolmade every rename silently disable auto-refresh. -
TOCTOU guard races — check + insert in separate statements/transactions.
-
Split-transaction crash holes — a row and its idempotency marker written in different transactions (T-06, T-26 — both closed; the pattern recurs).
-
Cursor boundaries —
<=vs<, resume token vs incremental boundary (seebackground-work). -
Unconsumed pagination — frontend fetching page one and ignoring
next_cursor(T-05). -
Reconciliation guard bypass — any new mutation path over postings must be guarded (see
ledger-invariants). -
Error-envelope drift — new error codes not added to the OpenAPI enum; raw Go errors leaking to clients.
-
i18n bypass — hard-coded English in UI or in
lib/api/. -
Logging financial content — forbidden at every level.
-
Builder-output tests that never reach the real consumer — a test that checks a spec-building function's return value in isolation (e.g.
buildTransactionSpec) is not the same as proving it survives the consumer's real validation.EntryKind: "main"sat inbuildTransactionSpecsince the import feature's first commit — invalid perentryKinds, so everyCommitImportBatchcall failed the instant it reachedTransactionService.CreateTransactionfor real — undetected because no test drove a staged row through the actual commit path against a real account (T-22). When testing a function that produces input for another service, add at least one test that calls the consumer for real, not just asserts on the producer's output shape. -
Creation dates masquerading as financial facts — stamping a record's
effective_from/opened_onwith "today" makes every earlier posting fail, so installing the app now and importing years of history breaks. Shipped three times: commodities (T-42), user-created categories (T-43), import-created holding accounts (T-44), each fixed by opening the record at the genesis date0001-01-01. Ask of any new dated container: is this date a real financial fact (accountopened_on— keep it, it should reject earlier postings) or app bookkeeping (everything above — genesis)? -
A duplicated helper is only as fixed as its least-visited copy — the decimal-comma 100x error has now shipped three times from the same two-line pattern,
input.replace(/,/g, '')before parsing, which reads1,50as 150. Fixed on the import side (T-36), then in the transaction editor and reconcile form (T-45), and it was still live in all three investment forms four months later (T-47) because the survey that scoped T-45 treated those forms as a later slice. The fix each time was correct; the sweep was what failed. So: when fixing a helper that exists in more than one place, grep the whole tree for the pattern before declaring it done, not just the copies the current ticket names — and count what you find, because the T-47 survey said two copies and there were seven. -
Consolidation that silently widens what is accepted — retiring a private helper onto a shared one is a behaviour change unless proven otherwise. The investment forms' parsers rejected a leading
-only as a side effect of running/^\d+$/over the concatenated coefficient;parseDecimalAmounthandles signs properly, so a like-for-like swap would have started accepting negative share quantities (T-47). Ask of any such swap: what did the old code reject incidentally that the new code accepts? Then make the rejection explicit and named, and test it where the behaviour changed — per call site, not once on the shared module.Corollary: if the call sites are
.sveltefiles, that test is impossible in place. This project has no component-test harness (notesting-library, nojsdom; vitest runs plain.tsonly), so the validation has to be extracted to a module first. That is a feature, not an obstacle — it is the same reason G-02 existed. -
Producer drafts mistaken for posted ledger changes — R9 generation initially inherited a create/edit reconciliation guard that checked dates without checking draft status (T-78). A test that creates the draft before reconciling cannot catch this: create and edit a producer draft after a checkpoint exists, prove no invalidation, then prove posting still requires its override. Draft-edit preview is not a promotion preview. Also test discard through the existing DELETE route: producer occurrence identity, its audit, and deletion must commit or roll back together (T-77).
-
Animating a control's disabled fade —
disabled:opacity-60is safe on its own: the contrast rules exempt an inactive control, and axe skips disabled elements forcolor-contrastoutright. Pairing it with a baretransitionis not, becausetransitioncovers opacity: clearingdisabledremoves the exemption one to three frames before the 150ms ramp off 0.6 finishes, and an axe run that lands in that gap measures an operable control at 4.37:1 (T-93, an intermittent failure of[acceptance] every report view is accessible). Usetransition-colorson any control whose disabled state clears on its own — a query settling rather than a click — so opacity stays a step function. Synchronising the test instead only moves the race, and costs the check its view of the loading state. -
A sound current projection hiding damaged disposal snapshots — original allocation quantity, basis and proceeds mutations passed self-check because current lots were compared with lot events, while replayed allocations only contributed quantity and basis to that projection. Proceeds corruption and damage to superseded revisions could pass silently. Check every original and revision allocation set independently against its snapshot totals, including missing allocations, before using effective evidence for a projection. Negative proceeds are valid; nonpositive allocated quantity and negative basis are not. Named regression:
TestSelfCheckDetectsDisposalAllocationConservationDamage. -
Offsetting disposal errors hiding behind sound group totals — combined proceeds and per-lot conservation can both pass after two decisions and their allocation proceeds are changed in opposite directions. Immutable decision-to-clearing portions must conserve each decision and each pinned posting independently. Check provenance even for equal-valued journals. Named regression:
TestSelfCheckDetectsOffsettingDisposalProceedsDamage. -
Unknown basis converted to a numeric zero — preserve NULL coefficient and scale with explicit knowledge, through read APIs and CSV. One unknown lot makes the position's basis/gain unavailable; quantity and independently priced market value stay available. Never use known-only numeric fields without checking knowledge. Keep quantity self-check active and refuse unresolved inputs before known-basis pooling/disposal/range arithmetic. Named regression:
TestUnknownProjectedBasisDoesNotBecomeZeroGain. -
Source identity lost on an imported correction descendant — import audit origin marks the replacement as imported, while committed identity effects stay on the original fill. A direct-only identity lookup incorrectly fences later sale corrections and hides effective specific-lot elections. Read committed source provenance through immutable correction ancestry, matching the writer's lineage guard; never copy or rebind original effects. Named regression:
TestCorrectTrading212SalePreservesSpecificElectionWithoutGuessingQuantityChanges. -
Order side mistaken for provider event type — BUY/SELL does not establish an ordinary execution. Preserve the provider fill taxonomy and require explicit TRADE before native import, generic cash fallback or source correction. Unknown/missing types fail closed. Order lifecycle status cannot cancel an execution and must not create economic revisions. Named regressions:
TestImportUnsupportedFillCannotPost,TestUnsupportedSaleFillCannotUseCashFallback,TestUnsupportedFillCannotCorrectAcceptedBuyOrSale. -
A preview reporting checkpoints for an impossible replay — buy replacement and plain-buy previews validated journal shape without testing dependent lots. Audit every replaying entry path, including admitted reinvestment and imports, rather than only correction commands. Run the proposed domain write in a rolled-back path before reporting impact, with the same ordering, lot lineage and elections as commit. Never accept staged source evidence or expose temporary IDs. Snapshot durable rows after failure and repeated successful previews; the actual write still rechecks dependencies and reconciliation. Named regression:
TestBuyReplacementPreviewRejectsDependentDisposalWithoutWritingandTestBuyPreviewMatchesCommitWhenTransferBasisPropagatesandTestReinvestmentPreviewPropagatesTransferBasisWithoutWriting(since T-132 a changed transfer basis propagates rather than refuses). Return the writer’s actual invalidated checkpoint set, including later checkpoints; a second latest-boundary calculation can underreport it. Pin multiple checkpoints inTestBuyPreviewReplaysAndRollsBackReconciledHistory. Shared reconciliation resolution must report all checkpoints the writer invalidates, once each, rather than only the latest checkpoint per candidate. Fix the common selector when generic transactions and other investment previews share it (T-127 #142). Named preview/commit pairs:TestCreatePreviewReportsEveryCheckpointCommitInvalidatesandTestSalePreviewReportsEveryCheckpointCommitInvalidates. Same-day sequence versus date-only cascade is tracked separately in T-120 #135. Gain disclosure now also runs through these writer previews (T-114 #129 / T-126 #141). -
Replay silently restating committed gains — a backdated or corrective command can change an earlier sale's basis/gain without touching any reconciled balance, so the reconciliation guard never fires. Any new replaying command must set
db.GainImpactPolicyon its first journal, returngain_impactfrom its preview, and acceptgain_impact_acknowledgementon commit; the writer recomputes and binds the set in-transaction. Test the changed, empty, stale and late-rollback cases. A preview must run the command's actual writer — reversal previews that planned only the inverse journal missed both impossible replays and gain changes (T-126). Comparing only inside replay persistence misses reversed/superseded disposals. Named regressions:TestBuyGainImpactDisclosesFIFORevisionAndRequiresExactAcknowledgement,TestBuyGainImpactRejectsAcknowledgementOfAnEarlierChangeSet,TestGainImpactSnapshotDisclosesRemovedAndReplacedDisposals. -
A new replay intent kind that moves quantity without pinning its journal — replay rebuilds lot state from intents, but a posted journal leg does not move with it. A split replayed under a corrected history can multiply a different number of shares than its posted
H +d; holdings and lots then disagree. Each journal-bearing intent must either refuse with itself named or post the difference as an adjustment journal linked to its operation and revision under the command's audit and checkpoint guard (T-129 does this for splits, ADR 0013 refinement); its per-lot replay output must be revisioned, and self-check must reconcile the operation's journals with its effective effects, not the originals. Once such an operation can be reversed or replaced, every reader of its latest revision must also filter to effective operations, and the self-check must count the successor's inverse journal (T-129). Named regressions:TestQuantityCorrectionBeforeSplitPostsAdjustmentJournal,TestSplitReversalInvertsPrimaryPlusAdjustmentDelta,TestReversalsBeforeSplitPostAdjustmentJournals,TestEarlierAcquisitionBasisCorrectionReplaysThroughSplit. -
A replay branch that omits a side effect its writer performs — replay re-derives a position from intents, so anything the commit path writes besides lot effects (the method-family lock, a link's original date, a revision row) must be re-derived by the matching replay branch, or the first replay of that position silently drops it. The selected-lots transfer branch cleared the source's individual-lot lock this way until the
investment_replay_equivalenceself-check (T-134) found it. New commands or intent kinds: assert the replay check passes after a replay of the touched positions (the shared self-check pass helpers do). Named regression:TestReplayKeepsSelectedLotTransferMethodLock. -
Revision rows read without asking whether their operation is still effective —
latest_*_revisionsviews select the newest revision per decision or link, not per effective operation. Once an operation can be reversed, its revisions stay as evidence and must not describe current state: self-check counted a reversed transfer's link revision as a live lot event until T-119 joinedeffective_investment_operations. Any new reader of a latest revision for current state joins it too. Named regression:TestReverseRevisedInternalTransferKeepsRevisionAsEvidence.
Fix workflow for any bug: failing named test first, then the fix, then the full relevant suite.
Docs to update in the same change (the docs ARE the product memory)
| What changed | Update |
|---|---|
| Feature shipped / status changed | docs/implemented.md (feature ledger); remove the item from the roadmap's current focus |
| Tech debt found or paid | GitHub Issue with a local ID (T-NN), exact file/line, and, when closing, the fix and validating test; map IDs used in the repo in docs/backlog.md |
| Durable product behavior/scope | docs/product-requirements.md |
| Repo-wide rule/convention | docs/conventions.md |
| Long-lived tradeoff decision | new ADR in docs/adrs/ (ADRs supersede everything once accepted) |
| Commands/layout/workflow | README.md + docs/developer-workflow.md (keep both consistent) |
Precedence when docs conflict: product-requirements → conventions →
early-architecture-decisions → ADRs govern all → developer-workflow.
AGENTS.md and skills are guidance, not product sources of truth.
.archive/ is historical reference only — never port from it directly.
Commits
Conventional Commits, smallest honest scope:
feat(backend): ..., fix(api): ..., docs(requirements): ...,
test(backend): .... Don't mix unrelated refactors.
Commit straight to main; do not open a branch. Pre-release with one
contributor, so there is no PR review to reach and branches were where the
2026-08 merge damage came from. The commit message is the only review artifact:
if a change alters product rules, conventions, or ADRs, say so in its body.
Branch only when the work genuinely needs isolation (a throwaway spike, or
something you want CI to see first). This flips at the v0.1.0 release — see
Branches And PRs in docs/developer-workflow.md, which governs.
Definition of done for a slice
- App still runnable end-to-end (
pnpm devorpnpm build). - Narrowest relevant validation green; new behavior has named tests.
- OpenAPI + generated types in sync (if API touched).
- Docs updated per the table above.
- No violation of
ledger-invariants(if money/ledger touched). - Focused conventional commit.
