Skip to content

GoFin services consolidation & hardening: shared platform layer, dead-code sweep, correctness fixes#38

Merged
ItsThompson merged 78 commits into
mainfrom
epic/gofin-consolidation
Jul 12, 2026
Merged

GoFin services consolidation & hardening: shared platform layer, dead-code sweep, correctness fixes#38
ItsThompson merged 78 commits into
mainfrom
epic/gofin-consolidation

Conversation

@ItsThompson

Copy link
Copy Markdown
Owner

Summary

Consolidates cross-service duplication in services/ into a shared platform layer, deletes
linter-invisible dead code, and fixes three correctness gaps surfaced by a read-only audit. Five
runnable services were originally scaffolded by copy-paste (~600–700 LOC of structural duplication,
no shared platform layer), plus dead exported symbols the unused linter structurally skips. All
modules passed go vet + golangci-lint clean before this work: this epic targets what the linters
cannot see.

Delivered as 13 reviewed tickets across 4 dependency-ordered waves. Every ticket landed with
per-module go build / go test / go vet / golangci-lint (pinned standard set) clean, -race
on all concurrency code, and an independent reviewer verification.

What changed

New shared modules (services/)

  • serverkit — bootstrap + serve/shutdown lifecycle. Serve fixes a zombie-on-bind-failure bug
    present in all 5 services (a fatal listener error was swallowed in a log-only goroutine): it now
    propagates the first fatal serve error, treats http.ErrServerClosed/gRPC graceful-stop as success
    via errors.Is, and performs a bounded 10s graceful shutdown.
  • apierr + httpx — single-sourced HTTP error contract (Error{Code,Message,Status,Fields},
    code constants, Respond via errors.As) and request guards (RequireUserID, BindJSON[T]).
    This closes a latent bug where auth/finance/datarights silently dropped the Fields slot on
    validation errors.
  • pgutil — the pgx/pgtype boilerplate each Postgres service re-solved: ParseUUID, IsNoRows
    (errors.Is), IsUniqueViolation (errors.As on 23505).

Per-service migration (finance, auth, datarights, gateway, expense)

  • run() bootstraps via serverkit; local ApiError/ServiceError/AuthError/handleError
    replaced by apierr.Respond; X-User-ID / JSON-bind guards replaced by httpx; gRPC handler
    type-assertions converted to errors.As; Postgres call sites (finance/auth) migrated to pgutil
    and both bespoke formatUUID copies deleted.
  • auth: JWT/cookie/cleanup config hoisted (cookie max-age now derives from the same source as the
    JWT TTLs); UpdateProfile own-record-missing now returns 401 to match the four-method majority;
    role strings → constants.
  • gateway: keeps its own reverse-proxy router; access.Registry/ProxyPrefixes are no longer
    reassignable exported globals (injected into router.New); access.Accessaccess.Level.

Correctness fixes (behavior-changing)

  • Expense advertised-but-ignored list filters (Sort/Type/TagID/DateFrom/DateTo) removed
    (the repo never read them; the frontend filters client-side). Response byte-identical for the same
    inputs.
  • Expense gRPC read RPCs (GetCorrectionHistory/GetProRataGroup) that hardcoded an empty ("")
    user scope removed (no in-workspace or frontend consumer; REST path is correctly scoped). Dev
    in-memory client cross-user select-by-id bypass removed; its silent no-op UPDATE now fails loud.
  • datarights rate-limit response: retryAfter JSON body field replaced by the standard Retry-After
    HTTP header (apierr stays generic; the frontend parses the unchanged message string).

Dead-code sweep

  • Removed verified-dead exported symbols: metrics DB collectors (incl. the active_connections
    gauge that polluted every service's /metrics), dbmigrate.Run + the source/file driver,
    access.Classifies, perf.AssertMaxAllocs, finance.CreatePeriod, 21 redundant gRPC
    Unimplemented stubs (auth's Register/Login "use REST" overrides preserved), and assorted dead
    fields/types. yearMonth deduped to a single definition.

datarights jobrunner (Slice F)

  • Extracted the bounded-worker-pool lifecycle both engines duplicated into
    datarights/internal/jobrunner (Pool + StatusStore seam + injected Execute strategy); the
    export/deletion engines stay separate and inject their own strategy.
  • Deletion providers collapsed into NewFuncProvider; export finance data fetched exactly once (the
    guarantee is now structural; MemoizedFinanceClient deleted); email templateData no longer
    mirrors BrandTokens field by field.

Tickets

# Wave Title
1 1 Dead-code sweep: shared/leaf modules
2 1 Dead-code sweep: service-internal symbols
3 1 serverkit module + serve/shutdown lifecycle (zombie fix)
4 1 apierr + httpx modules (error contract + guards)
5 1 pgutil module (UUID / no-rows / unique-violation)
6 2 Expense correctness fixes (C1–C4)
7 2 finance: serverkit + apierr/httpx + pgutil
8 2 auth: serverkit + apierr/httpx + pgutil + config hoist
10 2 datarights: serverkit + apierr/httpx + pgutil + config (C9)
11 2 gateway: serverkit.Serve + apierr + access hardening
9 3 expense: serverkit + apierr/httpx (no pgutil, immudb)
12 3 datarights jobrunner extraction (Pool + StatusStore)
13 4 datarights jobrunner provider consolidation

Verification

  • Full workspace gate on the merged branch: per-module go build / go vet / go test /
    golangci-lint run (v2.12.1, standard set) — all 14 modules clean, 0 lint issues.
  • go test -race clean on all concurrency code (serverkit.Serve, jobrunner.Pool, the datarights
    engines).
  • Behavior-preservation locked by tests: byte-identical export CSV goldens; exactly-once finance
    fetch; %w-wrapped error classification in gRPC handlers; real bind-failure run() tests;
    apierr Fields propagation.
  • Diff vs main: 186 files, +5,435 / −4,347.

Backwards compatibility

  • API error wire shape is unchanged except: Fields is now populated where it was silently dropped
    (additive), and the datarights rate-limit response moves retryAfter from the JSON body to the
    standard Retry-After header (the only non-additive change; no consumer read the old body field).
  • /health now returns {"status":"ok","service":"<name>"} for services adopting the shared router;
    probes only check HTTP 200, so this is compatible.

Known follow-ups (non-blocking)

  • go mod tidy would add additive transitive /go.mod hash lines to apierr/httpx/pgutil
    go.sum (pre-existing; builds green). Optional cleanup.
  • Three now-orphaned expense proto message types + two vestigial datarights mock methods retained
    (removal needs a coordinated cross-module change; generated files are out of epic scope). The C2
    security goal is fully met regardless.
  • services/ root is not a Go module, so root-level go build ./... / golangci-lint run ./...
    fail by design — per-module gates are the contract. Candidate for a separate fix.

Notes for reviewers

  • Full spec, per-ticket changesets, and reviews live under GoFinConsolidation-Spec/.
  • The branch is a sequence of --no-ff per-ticket merges; review per merge commit for a
    ticket-scoped diff.

- REST handler uses CreatePeriodWithProRata; the CreatePeriod service
  method had no callers and duplicated its validation block
- delete the method and its ~50 LOC divergent validation copy
- dashboard.go declared a func-local yearMonth identical in shape to the
  package-level one in prorata.go
- drop the local definition; both call sites now share one type
- remove 12 explicit Unimplemented RPC stubs; the embedded
  UnimplementedFinanceServiceServer already returns codes.Unimplemented
- refresh the stale struct doc comment to match
Create the services/pgutil leaf module with three specific helpers that
every Postgres-backed service currently re-implements inline:

- ParseUUID: string -> pgtype.UUID, standardizing the wrap message
  fmt.Errorf("parsing UUID: %w", err) across 38 call sites.
- IsNoRows: errors.Is(err, pgx.ErrNoRows) (19 sites, incl. datarights'
  non-idiomatic == comparisons).
- IsUniqueViolation: errors.As on *pgconn.PgError, SQLSTATE 23505,
  returning the constraint name (3 sites).

Specific over generic (no ScanOne[T]/NilOnNoRows[T]): sqlc row types
differ per query. Pins pgx to v5.9.2 to match the workspace and avoid a
workspace-wide MVS bump. Call-site migration and formatUUID deletion are
handled by the per-service tickets (#7/#8/#10).
Single-source the API error contract that is duplicated across the four
API services: the typed Error, the shared code constants, and the HTTP
wire mapping.

- Error{Code,Message,Status,Fields} with Unauthorized/NotFound/Validation/
  Conflict/Internal constructors.
- Respond(c, err) classifies via errors.As so %w-wrapped typed errors keep
  their status/code; unknown errors fall through to 500 INTERNAL_SERVER_ERROR.
- Exported wire type APIError adopts the initialism (US-HARDEN-04); JSON
  shape {code,message,fields?} is byte-identical to what services emit today.

Kept generic: no RateLimitError special-casing, no retryAfter wire field.
Module + 100%-covered unit tests only; per-service migration is #7-#11.
- remove 7 generic Unimplemented RPC stubs served by the embedded
  UnimplementedAuthServiceServer
- keep explicit Register/Login overrides that redirect to REST endpoints
- drop the companion TestGRPCStubs_ReturnUnimplemented and its helper
- only jwt_test.go called RefreshTokenTTL(); rest.go hardcodes 7*24h
- keep the refreshTokenTTL field; drop the accessor and its test
Consolidate the two request guards duplicated across HTTP handlers into a
shared module that depends on apierr (the only new module depending on
another new module).

- RequireUserID(c) reads the trusted gateway-injected X-User-ID header;
  writes a 401 apierr.Error and returns ok=false when missing/empty.
- BindJSON[T](c, dst) binds the body, writing a 400 apierr.Error with
  validator field detail on failure; returns false. Generic so call sites
  stay one line.

Module + 100%-covered unit tests only; handler migration is #7-#11.
- SchemaInitializer interface had no referents; concrete InitSchema stays
- SQLResult.Columns was never set or read
Delete DBQueryDuration, ActiveConnections, ObserveQuery, and
SetActiveConnections from the shared metrics module. ActiveConnections
was the only one that emitted a series, polluting every service's
/metrics with a permanent active_connections 0 gauge. The other three
were dead-but-invisible. Removes their companion assertions and the two
tests that referenced only these symbols.
Invoke the interceptor with a stub handler and assert grpc_requests_total
is recorded for both an OK and a NotFound status outcome, verifying the
handler runs and its response/error propagate. Exercises the interceptor
behaviorally rather than calling .Inc()/.Observe() on collectors directly
(US-HARDEN-03).
Remove the dead Run function (only its own tests called it; all 3
consumers use RunWithFS via source/iofs) along with the source/file
blank import, which drops that driver from the auth, finance, and
datarights binaries. RunWithFS and the rawConnectionURL/ensureSchema
helpers remain. Deletes the Run-only test file; RunWithFS coverage lives
in dbmigrate_embed_test.go.
- setIdentityHeaders reads only UserID/Role/AssumedBy; Username was set
  in grpc_validator.go but never read
- remove the field, its single write, and companion test references
Delete the test-only Classifies function and its inaccurate "services use
it" doc-comment. Its behavioral coverage is preserved by the resolver
suite: every-entry-is-classified is subsumed by
TestRegistry_EveryEntryResolvesToItsAccess, and the unknown-route deny
guardrail folds into TestResolve_FallsBackToDeny (adding the deep
unknown-service-path case). Registry and RoutesFor are retained (both
live internally via BindRoutes and coverage.go).
- datarights runs no gRPC server; GRPCPort and its GRPC_PORT parsing
  were never read
- the empty struct was referenced nowhere; export uses the header user ID
- Essentials/Desires/Savings were parsed from brand.json but never
  copied into templateData, so the template never used them
- drop the fields and their test fixture assignments
Remove allocs.go and allocs_test.go: AssertMaxAllocs had zero callers
(self-admittedly reserved for a future path). CallCounter (counter.go),
live in finance and datarights, is retained. Updates the package doc and
README so they no longer document the removed helper; the arch-stable
alloc-bound strategy now points at testing.AllocsPerRun directly.
Respond now treats a classified *Error with a non-positive Status the same
as an unknown error (500 INTERNAL_SERVER_ERROR) instead of emitting
WriteHeader(0). Unreachable via the five constructors, but hardens Respond
for the varied *apierr.Error construction coming in the per-service
migrations (#7-#11), keeping Respond total.

Also assert Message in the constructor table test.
Introduce the in-process shared module services/serverkit that owns the
bootstrap spine and serve/shutdown lifecycle the five Go services hand-write
today. Wires the module into services/go.work and mirrors the existing
shared-module dependency pattern (requires dbmigrate, metrics).

Exposes NewLogger, ConnectPostgres, NewRouter, NewGRPCServer, and Serve, all
taking primitive inputs so serverkit has no dependency on any service config
package.

Serve fixes the zombie-on-bind-failure bug in one place for all services: it
runs the HTTP and (optional) gRPC servers in goroutines that push their first
fatal error onto a buffered channel, selects between ctx cancellation (graceful
shutdown, return nil) and a fatal serve error (graceful shutdown, return the
error so run() can exit non-zero), and bounds shutdown to 10s. http.ErrServerClosed
and the gRPC graceful-stop signal are treated as clean exits via errors.Is.
Serve accepts a nil gRPC server and listener for the HTTP-only path.

This ticket builds the module only; migrating the services onto it is separate.
Add high-density unit tests for the new module. Densest coverage on Serve:
normal ctx cancellation returns nil (HTTP+gRPC and HTTP-only), an HTTP bind
failure returns a non-nil error without hanging (port pre-occupied), and
http.ErrServerClosed during graceful shutdown is treated as success, all
asserted via the Serve return value with a timeout guard against hangs.

NewLogger level filtering is tested through the public logger; the JSON output
and service attribute via a writer-injectable seam. NewRouter is covered for
/health, /metrics, and release mode. NewGRPCServer registers a minimal unary
service and asserts the shared metrics interceptor records the call.
ConnectPostgres covers the migration-failure boundary (no live DB).
Lock in the pollutant-gauge removal with a NotContains guard so a future
edit that reintroduces the gauge fails the endpoint test.
- Replace inlined logger/DB-connect/router/gRPC-server/serve/shutdown in run() with serverkit helpers; return serverkit.Serve so a REST bind failure propagates and the process exits non-zero (fixes C5 for finance)

- Source the --healthcheck probe port from config.ResolveRESTPort so it always matches the listener's REST_PORT/default (US-PLATFORM-05)

- Add serverkit require + replace ../serverkit to finance/go.mod
- NewFinanceService now takes expenseClient and nowFunc directly; delete the mutate-but-say-copy WithExpenseClient/WithNowFunc fluent builders (US-HARDEN-01)

- Route GetPeriodSummary's time.Now() through the injected nowFunc so dashboard pacing is deterministic in tests

- Drop the expenseClient nil-guards in DeleteTag and pro-rata now that the client is always injected (removes the dashboard nil-deref contradiction by construction)

- Update main.go and all service/handler test constructors to the folded signature
Ticket 1 removed perf.AssertMaxAllocs; the validate baseline note still named
it. Reword to 'no allocation-bound assertion is made for it' so the doc cites
no deleted symbol. Meaning (the guard is behavioral, not an allocation count)
is unchanged.
The UserID/Role struct-literal alignment went stale when Ticket 2 removed the
Username field from these fixtures. gofmt-clean it (the pinned linter set has
no gofmt check, so it was not caught).
Tighten the error-contract test from assert.JSONEq (semantic) to assert.Equal
on the raw body, pinning the byte-for-byte claim: field order (code, message)
and the absence of a trailing newline are now part of the guarded contract.
- Order the third import group by path so expensepb/pb sit in place instead of appended at the end
- Decode fields:{...} from a multi-field VALIDATION_ERROR response body to pin the C6 contract end-to-end (service Fields -> apierr wire)
Extract the bounded-worker-pool lifecycle shared by the export and deletion
engines into a new internal package. Pool owns the semaphore, the running
transition, the timeout-derived context, and the background-context
fail-on-timeout; engines inject their own Execute strategy and a StatusStore.

Sociable tests use a real Pool with a fake StatusStore and stub execute (no
mocking of the semaphore or goroutine) and cover: the running transition,
success -> CompleteJob, strategy error -> FailJob under a background context,
the timeout path, MaxConcurrent bounding, and live ActiveJobs/QueuedJobs.
Rename DeletionEngine -> Engine, DeletionProvider -> Provider,
DeletionProviderRegistry -> Registry, and their constructors
(NewDeletionEngine -> NewEngine, NewDeletionProviderRegistry -> NewRegistry).
The package is internal, so no external module references the old names. Pure
mechanical rename; behavior is unchanged (the pool extraction follows).
The deletion Engine now composes a jobrunner.Pool and injects its
sequential-retry-with-backoff loop as the Execute strategy. The pool owns the
semaphore, running transition, completion, and background-context failure; the
deletion job repo satisfies jobrunner.StatusStore directly and is handed to the
pool as the store. Removes the engine's now-dead private semaphore,
ActiveJobs/MaxConcurrent copies, and failJob helper.
…ool gauges

The export Engine now composes a jobrunner.Pool and injects its errgroup
fan-out + zip + email closure as the Execute strategy. Because export's
CompleteJob persists file_size_bytes and Submit carries userEmail (neither fits
the generic pool signatures), the engine adapts its repo behind the StatusStore
seam via a small statusStore type plus a per-job state map.

The pool now owns the semaphore, so export_pool_active_jobs and
export_pool_queued_jobs become GaugeFuncs that read pool.ActiveJobs()/
QueuedJobs() live at scrape time (wired via metrics.SetPoolStats from main),
preserving the Grafana dashboard and stuck-pool alert. Job business metrics
stay inside the injected strategy.
recoverJobs and recoverDeletionJobs collapsed into one generic helper that owns
the query -> empty-check -> per-job submit skeleton; each engine passes a submit
closure. Export retains its per-job email-resolve step; recovery on startup
still re-submits non-terminal jobs.
…emetry

Move the queued-counter increment from the worker goroutine into Submit so a
just-submitted job is counted as queued the instant Submit returns, rather than
only once its goroutine schedules. The decrement stays after slot acquisition;
still atomic and race-clean.
Replace the three near-identical deletion/providers/{auth,expense,finance}.go
structs (each wrapping one gRPC delete call) with deletion.NewFuncProvider(name,
fn). Registration in main.go now pairs a name with an idempotent delete closure,
preserving execution order (finance, expense, auth). The deletion engine's
retry/backoff execution strategy is unchanged; only provider construction
changes. Engine-level order/retry/fail-fast coverage stays in engine_test.go;
NewFuncProvider gets focused constructor + order/idempotency tests.
…nanceClient

The export engine now fetches GetAllUserData once per job in execute and hands
the resolved response to the provider factory. The finance-backed providers
(tags, budget_periods, default_settings) become pure response -> rows mappers,
and the expenses provider takes a pre-derived tag map (new BuildTagMap) instead
of self-fetching it. The auth-backed profile and stream-backed expenses
providers still self-fetch their own sources. finance_cache.go /
MemoizedFinanceClient are removed: the single-fetch guarantee is now structural,
asserted by TestExport_DedupesFinanceCalls (GetAllUserData exactly once,
ListTags zero), replacing the deleted MemoizedFinanceClient unit tests.
templateData now uses BrandColors/BrandTypography/BrandSpacing directly instead
of three parallel template* structs that mirrored them field by field, and
buildTemplateData assigns the token groups wholesale. The templates access the
same field names, so rendering is unchanged (covered by the existing HTML/text
rendering tests). No reference remains to the Essentials/Desires/Savings colors
removed in the dead-code sweep.
…etch

The dedup-target note named a per-job MemoizedFinanceClient serving
GetAllUserData at most once. That wrapper is gone: the export engine now fetches
GetAllUserData exactly once in execute and passes the response to pure-mapper
providers, so the guarantee is structural (one call site) and the assertion is
exactly-once, not at-most-once. Doc-only.
- Add serverkit/apierr/httpx/pgutil (and dbmigrate for gateway/expense)
  COPY lines to each service Dockerfile so GOWORK=off go mod download can
  read the epic's new replaced modules' go.mod.
- Unblocks the e2e Docker build and CD image build, which failed with
  "reading ../apierr/go.mod: no such file or directory".
- Verified by reproducing each Dockerfile's build context and running
  GOWORK=off download+build for all 5 services.
@ItsThompson
ItsThompson merged commit 34fb131 into main Jul 12, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant