Phase 0 — Foundation Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build the kernel and tooling every later module depends on — tenant isolation, money, time, errors, events, audit, numbering, registries, i18n and the codegen pipelines — so that no feature module ever has to invent them.
Architecture: A Go modular monolith. internal/kernel holds cross-cutting primitives with no knowledge of any domain; internal/platform holds reusable services that domains consume. Tenant isolation is enforced twice: application context plus Postgres RLS, with every request running inside a transaction that sets app.current_org. Conventions that cannot be enforced by the compiler are enforced by drift tests that read the database catalog and the source tree.
Tech Stack: Go 1.26+ · Gin · pgx v5 · sqlc · goose · shopspring/decimal · testify · testcontainers-go · oapi-codegen
Spec: docs/superpowers/specs/2026-09-20-erp-architecture-design.md
Global Constraints
Section titled “Global Constraints”- Go module path:
github.com/mcom/erp. Go 1.26 minimum (amended 2026-09-22 from 1.23:pressly/goose/v3requires it).go.modis the source of truth; CI derives its version from it rather than restating one. - Money is
NUMERIC(19,4)in Postgres andshopspring/decimalin Go. A float never touches money, in any layer. Money crosses the wire as a JSON string. - Every business table has
org_id UUID NOT NULL, RLS enabled and forced, and a policy namedorg_isolation. - Every request — including reads — runs inside a transaction that begins with
SET LOCAL app.current_org. - Every date-only value is midnight UTC.
- Vocabulary values are strings, never integers.
- Every user-facing name is a translation row, never a
VARCHARon the entity. - Generated artifacts (sqlc output, OpenAPI-derived types) are committed.
- Tests use real Postgres via testcontainers. No mocked database.
Task 1: Repository scaffolding, Makefile and CI
Section titled “Task 1: Repository scaffolding, Makefile and CI”Files:
- Create:
go.mod,Makefile,.env.example,.gitignore - Already present (do not recreate):
AGENTS.md,docs/ROADMAP.md - Create:
.github/workflows/ci.yml - Create:
internal/kernel/.keep,internal/platform/.keep,db/migrations/.keep,db/queries/.keep,api/.keep
Interfaces:
-
Consumes: nothing
-
Produces:
make test,make lint,make buildtargets used by every later task -
Step 1: Initialise the Go module
cd /Users/limziyang/Documents/repos/mcomtaskgo mod init github.com/mcom/erpmkdir -p cmd/api cmd/worker cmd/migrate internal/kernel internal/platform db/migrations db/queries api web packagestouch internal/kernel/.keep internal/platform/.keep db/migrations/.keep db/queries/.keep api/.keep- Step 2: Add the dependencies
go get github.com/gin-gonic/gin@latestgo get github.com/jackc/pgx/v5@latestgo get github.com/shopspring/decimal@latestgo get github.com/pressly/goose/v3@latestgo get github.com/google/uuid@latestgo get github.com/stretchr/testify@latestgo get github.com/testcontainers/testcontainers-go@latestgo get github.com/testcontainers/testcontainers-go/modules/postgres@latestgo mod tidy- Step 3: Write the Makefile
.PHONY: test lint build migrate generate
test: go test ./... -count=1
lint: go vet ./... gofmt -l -e .
build: go build -o bin/api ./cmd/api go build -o bin/worker ./cmd/worker go build -o bin/migrate ./cmd/migrate
migrate: go run ./cmd/migrate up
generate: sqlc generate go run ./tools/vocabgen- Step 4: Write
.gitignoreand.env.example
.gitignore:
bin/.env*.test.env.example — every variable the code reads must appear here; Task 8 enforces it:
# DatabaseDATABASE_URL=postgres://erp_app:erp@localhost:5432/erp?sslmode=disableDATABASE_MIGRATE_URL=postgres://postgres:postgres@localhost:5432/erp?sslmode=disable
# HTTPPORT=3000- Step 5: Read the conventions, do not rewrite them
AGENTS.md and docs/ROADMAP.md already exist at the repo root. Read both before
continuing — AGENTS.md carries the project invariants and the module-boundary rule, and
ROADMAP.md is the checklist this plan’s items are ticked on.
Run: test -f AGENTS.md && test -f docs/ROADMAP.md && echo ok
Expected: ok. If either is missing, stop and ask — they are inputs to this plan, not
outputs of it.
- Step 6: Write the CI workflow
.github/workflows/ci.yml:
name: CIon: [push, pull_request]jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: 'go.mod' - run: make lint - run: make test- Step 7: Verify it builds and commit
Run: go build ./... && make lint
Expected: no output, exit 0.
git add -Agit commit -m "chore: scaffold Go module, Makefile, CI and conventions"Task 2: Postgres test harness and migration runner
Section titled “Task 2: Postgres test harness and migration runner”Files:
- Create:
internal/kernel/db/db.go - Create:
internal/kernel/db/testdb/testdb.go - Create:
cmd/migrate/main.go - Create:
db/migrations/00001_init.sql
Interfaces:
-
Consumes: nothing
-
Produces:
db.Open(ctx context.Context, dsn string) (*db.DB, error)db.(*DB).Pool() *pgxpool.Pooltestdb.Start(t *testing.T) *db.DB— a migrated database per test packagetestdb.AdminPool(t *testing.T) *pgxpool.Pool— privileged pool for catalog queries
-
Step 1: Write the first migration
db/migrations/00001_init.sql:
-- +goose UpCREATE EXTENSION IF NOT EXISTS pgcrypto;
-- The application role. It deliberately has no BYPASSRLS: a query that forgets-- its tenant filter must return zero rows, not another tenant's data.DO $$BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'erp_app') THEN CREATE ROLE erp_app LOGIN PASSWORD 'erp'; END IF;END$$;
GRANT USAGE ON SCHEMA public TO erp_app;ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO erp_app;
-- +goose DownDROP OWNED BY erp_app;DROP ROLE IF EXISTS erp_app;- Step 2: Write the connection helper
internal/kernel/db/db.go:
package db
import ( "context" "fmt"
"github.com/jackc/pgx/v5/pgxpool")
type DB struct { pool *pgxpool.Pool}
func Open(ctx context.Context, dsn string) (*DB, error) { pool, err := pgxpool.New(ctx, dsn) if err != nil { return nil, fmt.Errorf("open pool: %w", err) } if err := pool.Ping(ctx); err != nil { pool.Close() return nil, fmt.Errorf("ping: %w", err) } return &DB{pool: pool}, nil}
func (d *DB) Pool() *pgxpool.Pool { return d.pool }
func (d *DB) Close() { d.pool.Close() }- Step 3: Write the migration entrypoint
cmd/migrate/main.go:
package main
import ( "database/sql" "log" "os"
"github.com/pressly/goose/v3" _ "github.com/jackc/pgx/v5/stdlib")
func main() { if len(os.Args) < 2 { log.Fatal("usage: migrate <up|down|status>") } dsn := os.Getenv("DATABASE_MIGRATE_URL") if dsn == "" { log.Fatal("DATABASE_MIGRATE_URL is required") } conn, err := sql.Open("pgx", dsn) if err != nil { log.Fatalf("open: %v", err) } defer conn.Close()
if err := goose.SetDialect("postgres"); err != nil { log.Fatalf("dialect: %v", err) } if err := goose.Run(os.Args[1], conn, "db/migrations"); err != nil { log.Fatalf("goose %s: %v", os.Args[1], err) }}- Step 4: Write the test harness
internal/kernel/db/testdb/testdb.go:
package testdb
import ( "context" "database/sql" "path/filepath" "runtime" "testing" "time"
_ "github.com/jackc/pgx/v5/stdlib" "github.com/jackc/pgx/v5/pgxpool" "github.com/pressly/goose/v3" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/postgres" "github.com/testcontainers/testcontainers-go/wait")
type Handles struct { AppDSN string AdminDSN string App *pgxpool.Pool Admin *pgxpool.Pool}
// Start boots a Postgres container, runs every migration, and returns pools for// the unprivileged application role and the privileged migration role.func Start(t *testing.T) *Handles { t.Helper() ctx := context.Background()
container, err := postgres.Run(ctx, "postgres:17-alpine", postgres.WithDatabase("erp"), postgres.WithUsername("postgres"), postgres.WithPassword("postgres"), testcontainers.WithWaitStrategy( wait.ForLog("database system is ready to accept connections"). WithOccurrence(2).WithStartupTimeout(60*time.Second), ), ) require.NoError(t, err) t.Cleanup(func() { _ = container.Terminate(ctx) })
adminDSN, err := container.ConnectionString(ctx, "sslmode=disable") require.NoError(t, err)
conn, err := sql.Open("pgx", adminDSN) require.NoError(t, err) defer conn.Close() require.NoError(t, goose.SetDialect("postgres")) require.NoError(t, goose.Up(conn, migrationsDir(t)))
host, err := container.Host(ctx) require.NoError(t, err) port, err := container.MappedPort(ctx, "5432") require.NoError(t, err) appDSN := "postgres://erp_app:erp@" + host + ":" + port.Port() + "/erp?sslmode=disable"
app, err := pgxpool.New(ctx, appDSN) require.NoError(t, err) t.Cleanup(app.Close)
admin, err := pgxpool.New(ctx, adminDSN) require.NoError(t, err) t.Cleanup(admin.Close)
return &Handles{AppDSN: appDSN, AdminDSN: adminDSN, App: app, Admin: admin}}
func migrationsDir(t *testing.T) string { t.Helper() _, file, _, ok := runtime.Caller(0) require.True(t, ok) return filepath.Join(filepath.Dir(file), "..", "..", "..", "..", "db", "migrations")}- Step 5: Write a test that proves the harness works
internal/kernel/db/testdb/testdb_test.go:
package testdb_test
import ( "context" "testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/db/testdb")
func TestStartRunsMigrations(t *testing.T) { h := testdb.Start(t)
var version int64 err := h.Admin.QueryRow(context.Background(), `SELECT max(version_id) FROM goose_db_version`).Scan(&version) require.NoError(t, err) require.GreaterOrEqual(t, version, int64(1))}
func TestAppRoleCannotBypassRLS(t *testing.T) { h := testdb.Start(t)
var bypass bool err := h.Admin.QueryRow(context.Background(), `SELECT rolbypassrls FROM pg_roles WHERE rolname = 'erp_app'`).Scan(&bypass) require.NoError(t, err) require.False(t, bypass, "erp_app must never have BYPASSRLS")}- Step 6: Run the tests
Run: go test ./internal/kernel/db/... -v
Expected: PASS. First run pulls the postgres image and takes ~30s.
- Step 7: Commit
git add go.mod go.sum db/migrations cmd/migrate internal/kernel/dbgit commit -m "feat(kernel): postgres test harness, migration runner and unprivileged app role"Task 3: kernel/clock — one definition of today
Section titled “Task 3: kernel/clock — one definition of today”Files:
- Create:
internal/kernel/clock/clock.go - Test:
internal/kernel/clock/clock_test.go
Interfaces:
-
Consumes: nothing
-
Produces:
clock.Clockinterface withNow() time.Timeclock.System() Clockclock.Fixed(t time.Time) Clockclock.TodayUTC(c Clock) time.Time
-
Step 1: Write the failing test
internal/kernel/clock/clock_test.go:
package clock_test
import ( "testing" "time"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/clock")
func TestTodayUTCTruncatesToMidnightUTC(t *testing.T) { c := clock.Fixed(time.Date(2026, 9, 20, 23, 45, 12, 500, time.UTC)) require.Equal(t, time.Date(2026, 9, 20, 0, 0, 0, 0, time.UTC), clock.TodayUTC(c))}
// A clock in a zone ahead of UTC must not report tomorrow. This is the bug the// single definition exists to prevent: a local-midnight answer sits hours off// the date-only columns it is compared against.func TestTodayUTCIgnoresLocalZone(t *testing.T) { kl := time.FixedZone("MYT", 8*60*60) c := clock.Fixed(time.Date(2026, 9, 21, 3, 0, 0, 0, kl)) // 2026-09-20 19:00 UTC require.Equal(t, time.Date(2026, 9, 20, 0, 0, 0, 0, time.UTC), clock.TodayUTC(c))}
func TestSystemClockReturnsUTC(t *testing.T) { require.Equal(t, time.UTC, clock.System().Now().Location())}- Step 2: Run the test to verify it fails
Run: go test ./internal/kernel/clock/... -v
Expected: FAIL — package github.com/mcom/erp/internal/kernel/clock is not in std
- Step 3: Write the implementation
internal/kernel/clock/clock.go:
// Package clock is the single definition of "now" and "today" for the whole// system.//// One definition rather than one per domain: a report defaulting its `asOf` has// no reason to disagree with a claim rejecting a future expense date about// where the day boundary falls.package clock
import "time"
type Clock interface { Now() time.Time}
type systemClock struct{}
func (systemClock) Now() time.Time { return time.Now().UTC() }
// System returns the real clock, always in UTC.func System() Clock { return systemClock{} }
type fixedClock struct{ t time.Time }
func (f fixedClock) Now() time.Time { return f.t }
// Fixed returns a clock pinned to t, for tests.func Fixed(t time.Time) Clock { return fixedClock{t: t} }
// TodayUTC is today's date at midnight UTC.//// UTC, not the process's zone, and deliberately so: every date-only column is// stored as midnight UTC, so a local-midnight answer would sit hours off the// values it gets compared against, and the direction of the error would depend// on where the process happened to be deployed.//// Built from the UTC parts rather than by zeroing the time on an instant, so it// reads as a construction of the day rather than a mutation of the moment.func TodayUTC(c Clock) time.Time { n := c.Now().UTC() return time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, time.UTC)}- Step 4: Run the test to verify it passes
Run: go test ./internal/kernel/clock/... -v
Expected: PASS, 3 tests.
- Step 5: Commit
git add internal/kernel/clockgit commit -m "feat(kernel): single UTC definition of now and today"Task 4: kernel/money — decimal amounts that never become floats
Section titled “Task 4: kernel/money — decimal amounts that never become floats”Files:
- Create:
internal/kernel/money/money.go - Test:
internal/kernel/money/money_test.go
Interfaces:
-
Consumes: nothing
-
Produces:
money.Amountvalue typemoney.Parse(s string) (Amount, error)money.MustParse(s string) Amountmoney.Zero() Amount(Amount) String() string,Add,Sub,Neg,IsNegative,IsZero,Cmp(Amount) MarshalJSON,(*Amount) UnmarshalJSON— string on the wire(Amount) Value()/(*Amount) Scan()forNUMERIC(19,4)
-
Step 1: Write the failing test
internal/kernel/money/money_test.go:
package money_test
import ( "encoding/json" "testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money")
func TestParseAndString(t *testing.T) { a, err := money.Parse("1234.56") require.NoError(t, err) require.Equal(t, "1234.5600", a.String())}
func TestArithmeticIsExact(t *testing.T) { // The canonical float failure: 0.1 + 0.2 != 0.3 in binary floating point. sum := money.MustParse("0.1").Add(money.MustParse("0.2")) require.Equal(t, money.MustParse("0.3").String(), sum.String())}
func TestMarshalsAsJSONString(t *testing.T) { b, err := json.Marshal(money.MustParse("1234.56")) require.NoError(t, err) require.Equal(t, `"1234.5600"`, string(b))}
// A JSON number is a double in the browser. Accepting one here would let a// rounding error in the client become a rounding error in the ledger.func TestUnmarshalRejectsJSONNumber(t *testing.T) { var a money.Amount err := json.Unmarshal([]byte(`1234.56`), &a) require.Error(t, err) require.Contains(t, err.Error(), "must be a JSON string")}
func TestUnmarshalAcceptsJSONString(t *testing.T) { var a money.Amount require.NoError(t, json.Unmarshal([]byte(`"99.9"`), &a)) require.Equal(t, "99.9000", a.String())}
func TestNegativeAndZero(t *testing.T) { require.True(t, money.MustParse("-1").IsNegative()) require.True(t, money.Zero().IsZero()) require.Equal(t, "-5.0000", money.MustParse("5").Neg().String())}- Step 2: Run the test to verify it fails
Run: go test ./internal/kernel/money/... -v
Expected: FAIL — package does not exist.
- Step 3: Write the implementation
internal/kernel/money/money.go:
// Package money is the only representation of a monetary value in the system.//// A float never touches money — not in a DTO, not in a JSON response, not in a// chart. Amounts are decimal in Go, NUMERIC(19,4) in Postgres, and strings on// the wire.package money
import ( "database/sql/driver" "encoding/json" "fmt"
"github.com/shopspring/decimal")
// Scale is the stored scale, matching NUMERIC(19,4).const Scale = 4
type Amount struct { d decimal.Decimal}
func Parse(s string) (Amount, error) { d, err := decimal.NewFromString(s) if err != nil { return Amount{}, fmt.Errorf("parse money %q: %w", s, err) } return Amount{d: d}, nil}
func MustParse(s string) Amount { a, err := Parse(s) if err != nil { panic(err) } return a}
func Zero() Amount { return Amount{d: decimal.Zero} }
func (a Amount) String() string { return a.d.StringFixed(Scale) }
func (a Amount) Add(b Amount) Amount { return Amount{d: a.d.Add(b.d)} }func (a Amount) Sub(b Amount) Amount { return Amount{d: a.d.Sub(b.d)} }func (a Amount) Neg() Amount { return Amount{d: a.d.Neg()} }
func (a Amount) IsNegative() bool { return a.d.IsNegative() }func (a Amount) IsZero() bool { return a.d.IsZero() }func (a Amount) Cmp(b Amount) int { return a.d.Cmp(b.d) }
func (a Amount) Decimal() decimal.Decimal { return a.d }
func (a Amount) MarshalJSON() ([]byte, error) { return json.Marshal(a.String())}
// UnmarshalJSON refuses a JSON number. A JSON number is a double on the// frontend, so accepting one would silently admit a rounding error into the// ledger. The wire format is a string.func (a *Amount) UnmarshalJSON(b []byte) error { if len(b) == 0 || b[0] != '"' { return fmt.Errorf("money must be a JSON string, got %s", string(b)) } var s string if err := json.Unmarshal(b, &s); err != nil { return err } parsed, err := Parse(s) if err != nil { return err } *a = parsed return nil}
func (a Amount) Value() (driver.Value, error) { return a.String(), nil }
func (a *Amount) Scan(src any) error { switch v := src.(type) { case nil: *a = Zero() return nil case string: parsed, err := Parse(v) if err != nil { return err } *a = parsed return nil case []byte: parsed, err := Parse(string(v)) if err != nil { return err } *a = parsed return nil default: return fmt.Errorf("cannot scan %T into money.Amount", src) }}- Step 4: Run the test to verify it passes
Run: go test ./internal/kernel/money/... -v
Expected: PASS, 6 tests.
- Step 5: Commit
git add internal/kernel/moneygit commit -m "feat(kernel): decimal money type that refuses JSON numbers"Task 5: kernel/errors — one error taxonomy
Section titled “Task 5: kernel/errors — one error taxonomy”Files:
- Create:
internal/kernel/errors/problem.go - Create:
internal/kernel/errors/pgmap.go - Test:
internal/kernel/errors/problem_test.go
Interfaces:
-
Consumes: nothing
-
Produces:
errors.Problemstruct withType, Title, Status, Detail, Instanceerrors.New(kind Kind, detail string) *Problem- Kinds:
KindValidation, KindNotFound, KindConflict, KindForbidden, KindUnauthorized, KindInternal errors.FromPg(err error) *Problem(*Problem) Error() string
-
Step 1: Write the failing test
internal/kernel/errors/problem_test.go:
package errors_test
import ( "encoding/json" stderrors "errors" "testing"
"github.com/jackc/pgx/v5/pgconn" "github.com/stretchr/testify/require"
apperr "github.com/mcom/erp/internal/kernel/errors")
func TestProblemSerialisesAsRFC7807(t *testing.T) { p := apperr.New(apperr.KindNotFound, "claim 42 does not exist") b, err := json.Marshal(p) require.NoError(t, err)
var got map[string]any require.NoError(t, json.Unmarshal(b, &got)) require.Equal(t, "https://errors.mcom.erp/not-found", got["type"]) require.Equal(t, float64(404), got["status"]) require.Equal(t, "claim 42 does not exist", got["detail"])}
func TestUniqueViolationBecomesConflict(t *testing.T) { pgErr := &pgconn.PgError{Code: "23505", ConstraintName: "payout_channel_key_uniq"} p := apperr.FromPg(pgErr) require.Equal(t, 409, p.Status) require.Contains(t, p.Detail, "payout_channel_key_uniq")}
func TestForeignKeyViolationBecomesValidation(t *testing.T) { pgErr := &pgconn.PgError{Code: "23503", ConstraintName: "claim_channel_fk"} require.Equal(t, 422, apperr.FromPg(pgErr).Status)}
func TestCheckViolationBecomesValidation(t *testing.T) { pgErr := &pgconn.PgError{Code: "23514", ConstraintName: "journal_balanced"} require.Equal(t, 422, apperr.FromPg(pgErr).Status)}
func TestUnknownErrorBecomesInternal(t *testing.T) { p := apperr.FromPg(stderrors.New("boom")) require.Equal(t, 500, p.Status) // A database message is not a safe thing to hand a caller. require.Equal(t, "an internal error occurred", p.Detail)}- Step 2: Run the test to verify it fails
Run: go test ./internal/kernel/errors/... -v
Expected: FAIL — package does not exist.
- Step 3: Write the Problem type
internal/kernel/errors/problem.go:
// Package errors is the single translation from internal failures to the// HTTP surface. Handlers never inspect driver errors; each module translating// its own is how the same failure ends up with three different status codes.package errors
import "fmt"
type Kind string
const ( KindValidation Kind = "validation" KindNotFound Kind = "not-found" KindConflict Kind = "conflict" KindForbidden Kind = "forbidden" KindUnauthorized Kind = "unauthorized" KindInternal Kind = "internal")
var kindStatus = map[Kind]int{ KindValidation: 422, KindNotFound: 404, KindConflict: 409, KindForbidden: 403, KindUnauthorized: 401, KindInternal: 500,}
var kindTitle = map[Kind]string{ KindValidation: "Validation failed", KindNotFound: "Not found", KindConflict: "Conflict", KindForbidden: "Forbidden", KindUnauthorized: "Unauthorized", KindInternal: "Internal error",}
// Problem is an RFC 7807 application/problem+json body. `Type` is a stable URI// so the frontend branches on a code rather than on a message.type Problem struct { Type string `json:"type"` Title string `json:"title"` Status int `json:"status"` Detail string `json:"detail,omitempty"` Instance string `json:"instance,omitempty"`}
func New(kind Kind, detail string) *Problem { return &Problem{ Type: "https://errors.mcom.erp/" + string(kind), Title: kindTitle[kind], Status: kindStatus[kind], Detail: detail, }}
func (p *Problem) Error() string { return fmt.Sprintf("%s (%d): %s", p.Title, p.Status, p.Detail)}- Step 4: Write the Postgres mapping
internal/kernel/errors/pgmap.go:
package errors
import ( stderrors "errors" "fmt"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn")
// FromPg maps a driver error onto a Problem. Anything unrecognised becomes an// opaque internal error: a database message is not a safe thing to hand a// caller.func FromPg(err error) *Problem { if err == nil { return nil } if stderrors.Is(err, pgx.ErrNoRows) { return New(KindNotFound, "the requested record does not exist") }
var pgErr *pgconn.PgError if stderrors.As(err, &pgErr) { switch pgErr.Code { case "23505": // unique_violation return New(KindConflict, fmt.Sprintf("a record violating %s already exists", pgErr.ConstraintName)) case "23503": // foreign_key_violation return New(KindValidation, fmt.Sprintf("referenced record missing for %s", pgErr.ConstraintName)) case "23514": // check_violation return New(KindValidation, fmt.Sprintf("constraint %s was violated", pgErr.ConstraintName)) case "23502": // not_null_violation return New(KindValidation, fmt.Sprintf("%s is required", pgErr.ColumnName)) case "40001": // serialization_failure return New(KindConflict, "the record changed concurrently; retry") } } return New(KindInternal, "an internal error occurred")}- Step 5: Run the test to verify it passes
Run: go test ./internal/kernel/errors/... -v
Expected: PASS, 5 tests.
- Step 6: Commit
git add internal/kernel/errorsgit commit -m "feat(kernel): RFC 7807 problem type and Postgres error mapping"Task 6: kernel/tenancy and the transactional RLS boundary
Section titled “Task 6: kernel/tenancy and the transactional RLS boundary”Files:
- Create:
db/migrations/00002_organization.sql - Create:
internal/kernel/tenancy/tenancy.go - Modify:
internal/kernel/db/db.go— addInTx - Test:
internal/kernel/db/intx_test.go
Interfaces:
-
Consumes:
db.Open,testdb.Start -
Produces:
tenancy.WithOrg(ctx context.Context, orgID uuid.UUID) context.Contexttenancy.OrgFromContext(ctx context.Context) (uuid.UUID, bool)db.(*DB).InTx(ctx context.Context, fn func(ctx context.Context, tx pgx.Tx) error) error—fnreceives the transaction’s own context, which Task 10 will use to carry the event buffer. Taking it from the start means no call site has to change later.
-
Step 1: Write the organization migration with the RLS pattern
db/migrations/00002_organization.sql:
-- +goose UpCREATE TABLE organization ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL, name TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT organization_self_scoped CHECK (org_id = id));
-- FORCE, not just ENABLE: the table owner bypasses RLS otherwise, which would-- make every test that runs as the owner pass while production fails.ALTER TABLE organization ENABLE ROW LEVEL SECURITY;ALTER TABLE organization FORCE ROW LEVEL SECURITY;
-- current_setting(..., true) returns NULL rather than raising when the setting-- is absent, so a query outside a tenant transaction matches nothing instead of-- erroring. Zero rows is the safe failure.CREATE POLICY org_isolation ON organization USING (org_id = current_setting('app.current_org', true)::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON organization TO erp_app;
-- +goose DownDROP TABLE organization;- Step 2: Write the tenancy context
internal/kernel/tenancy/tenancy.go:
// Package tenancy carries the active organization for a request.//// Handlers never read an organization id from a request body: it comes only// from here, having been resolved from the caller's credentials.package tenancy
import ( "context"
"github.com/google/uuid")
type ctxKey struct{}
func WithOrg(ctx context.Context, orgID uuid.UUID) context.Context { return context.WithValue(ctx, ctxKey{}, orgID)}
func OrgFromContext(ctx context.Context) (uuid.UUID, bool) { id, ok := ctx.Value(ctxKey{}).(uuid.UUID) return id, ok}- Step 3: Add InTx to the DB type
Append to internal/kernel/db/db.go:
import ( // ...existing imports... "errors"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/tenancy")
// ErrNoTenant is returned when a transaction is opened without an organization// in context. Every query runs inside a tenant transaction; there is no// untenanted path by design.var ErrNoTenant = errors.New("no organization in context")
// InTx runs fn inside a transaction that has set the tenant for the duration.//// SET LOCAL is transaction-scoped, so the setting dies with the transaction and// cannot leak onto the next borrower of a pooled connection. That is what makes// this safe under pgbouncer transaction pooling — and it is why reads run in a// transaction too.func (d *DB) InTx(ctx context.Context, fn func(ctx context.Context, tx pgx.Tx) error) error { orgID, ok := tenancy.OrgFromContext(ctx) if !ok { return ErrNoTenant }
tx, err := d.pool.Begin(ctx) if err != nil { return fmt.Errorf("begin: %w", err) } defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, "SELECT set_config('app.current_org', $1, true)", orgID.String()); err != nil { return fmt.Errorf("set tenant: %w", err) }
if err := fn(ctx, tx); err != nil { return err } return tx.Commit(ctx)}Note: set_config(..., true) is the parameterisable form of SET LOCAL; SET LOCAL itself does not accept bind parameters.
- Step 4: Write the isolation tests
internal/kernel/db/intx_test.go:
package db_test
import ( "context" "testing"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/db" "github.com/mcom/erp/internal/kernel/db/testdb" "github.com/mcom/erp/internal/kernel/tenancy")
func seedOrg(t *testing.T, h *testdb.Handles, name string) uuid.UUID { t.Helper() id := uuid.New() _, err := h.Admin.Exec(context.Background(), `INSERT INTO organization (id, org_id, name) VALUES ($1, $1, $2)`, id, name) require.NoError(t, err) return id}
func TestInTxRequiresTenant(t *testing.T) { h := testdb.Start(t) d, err := db.Open(context.Background(), h.AppDSN) require.NoError(t, err) t.Cleanup(d.Close)
err = d.InTx(context.Background(), func(txCtx context.Context, tx pgx.Tx) error { return nil }) require.ErrorIs(t, err, db.ErrNoTenant)}
func TestRLSHidesOtherTenants(t *testing.T) { h := testdb.Start(t) alpha := seedOrg(t, h, "Alpha") seedOrg(t, h, "Beta")
d, err := db.Open(context.Background(), h.AppDSN) require.NoError(t, err) t.Cleanup(d.Close)
ctx := tenancy.WithOrg(context.Background(), alpha)
var names []string require.NoError(t, d.InTx(ctx, func(txCtx context.Context, tx pgx.Tx) error { rows, err := tx.Query(txCtx, `SELECT name FROM organization`) if err != nil { return err } defer rows.Close() for rows.Next() { var n string if err := rows.Scan(&n); err != nil { return err } names = append(names, n) } return rows.Err() }))
// The query has no WHERE clause at all. RLS is what makes this correct. require.Equal(t, []string{"Alpha"}, names)}
func TestTenantSettingDoesNotLeakBetweenTransactions(t *testing.T) { h := testdb.Start(t) alpha := seedOrg(t, h, "Alpha")
d, err := db.Open(context.Background(), h.AppDSN) require.NoError(t, err) t.Cleanup(d.Close)
require.NoError(t, d.InTx(tenancy.WithOrg(context.Background(), alpha), func(txCtx context.Context, tx pgx.Tx) error { return nil }))
// A second transaction with a different tenant must see nothing of Alpha's. other := uuid.New() var count int require.NoError(t, d.InTx(tenancy.WithOrg(context.Background(), other), func(txCtx context.Context, tx pgx.Tx) error { return tx.QueryRow(txCtx, `SELECT count(*) FROM organization`).Scan(&count) })) require.Equal(t, 0, count)}- Step 5: Run the tests
Run: go test ./internal/kernel/db/... -run 'InTx|RLS|Tenant' -v
Expected: PASS, 3 tests.
- Step 6: Commit
git add db/migrations/00002_organization.sql internal/kernel/tenancy internal/kernel/dbgit commit -m "feat(kernel): transactional tenant boundary with forced RLS"Task 7: Drift test — every table has org_id and an RLS policy
Section titled “Task 7: Drift test — every table has org_id and an RLS policy”Files:
- Create:
internal/kernel/db/rlscoverage_test.go - Create:
db/rls_exempt.txt
Interfaces:
-
Consumes:
testdb.Start -
Produces: a failing test whenever a migration adds a table without tenant isolation
-
Step 1: Write the exemption list
db/rls_exempt.txt — every entry is a deliberate decision, not an oversight:
# Tables that are legitimately not tenant-scoped.# Adding a line here is a decision. Explain it.
# goose's own bookkeeping.goose_db_version- Step 2: Write the failing test
internal/kernel/db/rlscoverage_test.go:
package db_test
import ( "bufio" "context" "os" "path/filepath" "runtime" "strings" "testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/db/testdb")
// Section 4.1's second isolation layer is worthless on the table someone// forgot. This test is the enforcement.func TestEveryTableIsTenantIsolated(t *testing.T) { h := testdb.Start(t) exempt := loadExempt(t)
rows, err := h.Admin.Query(context.Background(), ` SELECT c.relname, c.relrowsecurity, c.relforcerowsecurity, EXISTS (SELECT 1 FROM information_schema.columns col WHERE col.table_schema = 'public' AND col.table_name = c.relname AND col.column_name = 'org_id') AS has_org_id, EXISTS (SELECT 1 FROM pg_policies p WHERE p.schemaname = 'public' AND p.tablename = c.relname AND p.policyname = 'org_isolation') AS has_policy FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'public' AND c.relkind = 'r' ORDER BY c.relname`) require.NoError(t, err) defer rows.Close()
var checked int for rows.Next() { var name string var rlsEnabled, rlsForced, hasOrgID, hasPolicy bool require.NoError(t, rows.Scan(&name, &rlsEnabled, &rlsForced, &hasOrgID, &hasPolicy)) if exempt[name] { continue } checked++ require.True(t, hasOrgID, "table %s has no org_id column", name) require.True(t, rlsEnabled, "table %s does not have RLS enabled", name) require.True(t, rlsForced, "table %s does not FORCE RLS; the owner would bypass it", name) require.True(t, hasPolicy, "table %s has no policy named org_isolation", name) } require.NoError(t, rows.Err()) require.Positive(t, checked, "no tables were checked; the query is wrong")}
func loadExempt(t *testing.T) map[string]bool { t.Helper() _, file, _, ok := runtime.Caller(0) require.True(t, ok) path := filepath.Join(filepath.Dir(file), "..", "..", "..", "db", "rls_exempt.txt")
f, err := os.Open(path) require.NoError(t, err) defer f.Close()
out := map[string]bool{} sc := bufio.NewScanner(f) for sc.Scan() { line := strings.TrimSpace(sc.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } out[line] = true } require.NoError(t, sc.Err()) return out}- Step 3: Run the test to verify it passes on the current schema
Run: go test ./internal/kernel/db/... -run TestEveryTableIsTenantIsolated -v
Expected: PASS — only organization is checked.
- Step 4: Prove the test actually catches a violation
Temporarily add to the end of db/migrations/00002_organization.sql before -- +goose Down:
CREATE TABLE leaky (id UUID PRIMARY KEY);Run: go test ./internal/kernel/db/... -run TestEveryTableIsTenantIsolated -v
Expected: FAIL with table leaky has no org_id column.
Then remove the leaky table and re-run to confirm PASS. A drift test that has never been seen to fail is not known to work.
- Step 5: Commit
git add db/rls_exempt.txt internal/kernel/db/rlscoverage_test.gogit commit -m "test(kernel): fail the build when a table is not tenant-isolated"Task 8: Drift test — every env var is documented
Section titled “Task 8: Drift test — every env var is documented”Files:
- Create:
internal/kernel/config/config.go - Create:
internal/kernel/config/envexample_test.go
Interfaces:
-
Consumes: nothing
-
Produces:
config.Load() (config.Config, error)config.Config{DatabaseURL, DatabaseMigrateURL, Port string}
-
Step 1: Write the config loader
internal/kernel/config/config.go:
// Package config reads process configuration. Every variable read here must// appear in .env.example; envexample_test.go enforces that.package config
import ( "fmt" "os")
type Config struct { DatabaseURL string DatabaseMigrateURL string Port string}
func Load() (Config, error) { c := Config{ DatabaseURL: os.Getenv("DATABASE_URL"), DatabaseMigrateURL: os.Getenv("DATABASE_MIGRATE_URL"), Port: envOr("PORT", "3000"), } if c.DatabaseURL == "" { return Config{}, fmt.Errorf("DATABASE_URL is required") } return c, nil}
func envOr(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback}- Step 2: Write the failing drift test
internal/kernel/config/envexample_test.go:
package config_test
import ( "os" "path/filepath" "regexp" "runtime" "strings" "testing"
"github.com/stretchr/testify/require")
// The example is documentation, and documentation does not fail a build — until// it does. korre-pm-be found 15 variables the code read and the example never// mentioned, the entire mail block among them, so a new environment booted with// password reset silently writing reset links to the log.func TestEveryEnvVarIsInExample(t *testing.T) { root := repoRoot(t)
documented := parseEnvExample(t, filepath.Join(root, ".env.example")) read := scanEnvReads(t, root)
var missing []string for name := range read { if !documented[name] { missing = append(missing, name) } } require.Empty(t, missing, "these variables are read but absent from .env.example: %v", missing)}
var envReadRe = regexp.MustCompile(`os\.Getenv\("([A-Z0-9_]+)"\)`)
func scanEnvReads(t *testing.T, root string) map[string]bool { t.Helper() out := map[string]bool{} err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { switch d.Name() { case ".git", "bin", "node_modules", "web": return filepath.SkipDir } return nil } if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { return nil } b, err := os.ReadFile(path) if err != nil { return err } for _, m := range envReadRe.FindAllStringSubmatch(string(b), -1) { out[m[1]] = true } return nil }) require.NoError(t, err) return out}
func parseEnvExample(t *testing.T, path string) map[string]bool { t.Helper() b, err := os.ReadFile(path) require.NoError(t, err) out := map[string]bool{} for _, line := range strings.Split(string(b), "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } if k, _, ok := strings.Cut(line, "="); ok { out[strings.TrimSpace(k)] = true } } return out}
func repoRoot(t *testing.T) string { t.Helper() _, file, _, ok := runtime.Caller(0) require.True(t, ok) return filepath.Join(filepath.Dir(file), "..", "..", "..")}- Step 3: Run the test
Run: go test ./internal/kernel/config/... -v
Expected: PASS — DATABASE_URL, DATABASE_MIGRATE_URL and PORT are all in .env.example.
- Step 4: Prove the test catches a violation
Temporarily add to config.go: _ = os.Getenv("SMTP_PASSWORD")
Run: go test ./internal/kernel/config/... -v
Expected: FAIL listing SMTP_PASSWORD.
Remove the line and re-run to confirm PASS.
- Step 5: Commit
git add internal/kernel/configgit commit -m "test(kernel): fail the build when an env var is undocumented"Task 9: kernel/audit — the trail that commits with the change
Section titled “Task 9: kernel/audit — the trail that commits with the change”Files:
- Create:
db/migrations/00003_audit_log.sql - Create:
internal/kernel/audit/audit.go - Test:
internal/kernel/audit/audit_test.go
Interfaces:
-
Consumes:
db.InTx,tenancy,clock -
Produces:
audit.Entry{EntityKind, EntityID, Action, ActorID, Before, After any}audit.Write(ctx context.Context, tx pgx.Tx, c clock.Clock, e Entry) error
-
Step 1: Write the migration
db/migrations/00003_audit_log.sql:
-- +goose UpCREATE TABLE audit_log ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL, entity_kind TEXT NOT NULL, entity_id UUID NOT NULL, action TEXT NOT NULL, actor_id UUID, before JSONB, after JSONB, occurred_at TIMESTAMPTZ NOT NULL);
CREATE INDEX audit_log_entity_idx ON audit_log (org_id, entity_kind, entity_id, occurred_at DESC);
ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY;ALTER TABLE audit_log FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON audit_log USING (org_id = current_setting('app.current_org', true)::uuid);
-- Append-only: the trail is worthless if it can be edited.REVOKE UPDATE, DELETE ON audit_log FROM erp_app;GRANT SELECT, INSERT ON audit_log TO erp_app;
-- +goose DownDROP TABLE audit_log;- Step 2: Write the failing test
internal/kernel/audit/audit_test.go:
package audit_test
import ( "context" "testing" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/db" "github.com/mcom/erp/internal/kernel/db/testdb" "github.com/mcom/erp/internal/kernel/tenancy")
func TestWritePersistsBeforeAndAfter(t *testing.T) { h := testdb.Start(t) org := uuid.New() _, err := h.Admin.Exec(context.Background(), `INSERT INTO organization (id, org_id, name) VALUES ($1,$1,'Alpha')`, org) require.NoError(t, err)
d, err := db.Open(context.Background(), h.AppDSN) require.NoError(t, err) t.Cleanup(d.Close)
c := clock.Fixed(time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)) ctx := tenancy.WithOrg(context.Background(), org) entity := uuid.New()
require.NoError(t, d.InTx(ctx, func(txCtx context.Context, tx pgx.Tx) error { return audit.Write(txCtx, tx, c, audit.Entry{ EntityKind: "expense_claim", EntityID: entity, Action: "approve", Before: map[string]any{"state": "reviewed"}, After: map[string]any{"state": "approved"}, }) }))
var action string var after map[string]any require.NoError(t, d.InTx(ctx, func(txCtx context.Context, tx pgx.Tx) error { return tx.QueryRow(txCtx, `SELECT action, after FROM audit_log WHERE entity_id = $1`, entity). Scan(&action, &after) })) require.Equal(t, "approve", action) require.Equal(t, "approved", after["state"])}
// An audited action that commits without its trail is the failure this design// exists to prevent.func TestTrailRollsBackWithTheChange(t *testing.T) { h := testdb.Start(t) org := uuid.New() _, err := h.Admin.Exec(context.Background(), `INSERT INTO organization (id, org_id, name) VALUES ($1,$1,'Alpha')`, org) require.NoError(t, err)
d, err := db.Open(context.Background(), h.AppDSN) require.NoError(t, err) t.Cleanup(d.Close)
ctx := tenancy.WithOrg(context.Background(), org) entity := uuid.New()
err = d.InTx(ctx, func(txCtx context.Context, tx pgx.Tx) error { if err := audit.Write(txCtx, tx, clock.System(), audit.Entry{ EntityKind: "expense_claim", EntityID: entity, Action: "approve", }); err != nil { return err } return pgx.ErrTxClosed // any error aborts the transaction }) require.Error(t, err)
var count int require.NoError(t, d.InTx(ctx, func(txCtx context.Context, tx pgx.Tx) error { return tx.QueryRow(txCtx, `SELECT count(*) FROM audit_log WHERE entity_id = $1`, entity).Scan(&count) })) require.Zero(t, count)}
func TestAppRoleCannotUpdateAuditLog(t *testing.T) { h := testdb.Start(t) var canUpdate bool require.NoError(t, h.Admin.QueryRow(context.Background(), `SELECT has_table_privilege('erp_app', 'audit_log', 'UPDATE')`).Scan(&canUpdate)) require.False(t, canUpdate, "audit_log must be append-only for the app role")}- Step 3: Run the test to verify it fails
Run: go test ./internal/kernel/audit/... -v
Expected: FAIL — package does not exist.
- Step 4: Write the implementation
internal/kernel/audit/audit.go:
// Package audit writes the compliance trail.//// Written in the same transaction as the change it records, so an audited// action cannot commit without its trail. Distinct from the activity log, which// is the user-facing feed, and from request logs, which are telemetry.package audit
import ( "context" "fmt"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/tenancy")
type Entry struct { EntityKind string EntityID uuid.UUID Action string ActorID *uuid.UUID Before any After any}
func Write(ctx context.Context, tx pgx.Tx, c clock.Clock, e Entry) error { orgID, ok := tenancy.OrgFromContext(ctx) if !ok { return fmt.Errorf("audit: no organization in context") } _, err := tx.Exec(ctx, ` INSERT INTO audit_log (org_id, entity_kind, entity_id, action, actor_id, before, after, occurred_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, orgID, e.EntityKind, e.EntityID, e.Action, e.ActorID, e.Before, e.After, c.Now()) if err != nil { return fmt.Errorf("audit write: %w", err) } return nil}- Step 5: Run the tests
Run: go test ./internal/kernel/audit/... -v
Expected: PASS, 3 tests.
- Step 6: Commit
git add db/migrations/00003_audit_log.sql internal/kernel/auditgit commit -m "feat(kernel): append-only audit trail written in the change's transaction"Task 10: kernel/eventbus — in-process events that fire only after commit
Section titled “Task 10: kernel/eventbus — in-process events that fire only after commit”Files:
- Create:
internal/kernel/eventbus/eventbus.go - Modify:
internal/kernel/db/db.go— dispatch buffered events after commit - Test:
internal/kernel/eventbus/eventbus_test.go
Interfaces:
-
Consumes:
db.InTx -
Produces:
eventbus.BuswithSubscribe(kind string, h Handler)anddispatcheventbus.Handler func(ctx context.Context, payload any) erroreventbus.WithBuffer(ctx context.Context) (context.Context, *Buffer)eventbus.Publish(ctx context.Context, kind string, payload any)(*Buffer) Flush(ctx context.Context, b *Bus) error
-
Step 1: Write the failing test
internal/kernel/eventbus/eventbus_test.go:
package eventbus_test
import ( "context" "testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/eventbus")
func TestBufferedEventsFireOnFlush(t *testing.T) { bus := eventbus.New() var got []string bus.Subscribe("claim.approved", func(ctx context.Context, p any) error { got = append(got, p.(string)) return nil })
ctx, buf := eventbus.WithBuffer(context.Background()) eventbus.Publish(ctx, "claim.approved", "CLM-1") require.Empty(t, got, "events must not fire before flush")
require.NoError(t, buf.Flush(ctx, bus)) require.Equal(t, []string{"CLM-1"}, got)}
func TestDiscardedBufferFiresNothing(t *testing.T) { bus := eventbus.New() fired := false bus.Subscribe("claim.approved", func(ctx context.Context, p any) error { fired = true return nil })
ctx, _ := eventbus.WithBuffer(context.Background()) eventbus.Publish(ctx, "claim.approved", "CLM-1") // No Flush: the transaction rolled back, so nothing may be observable. require.False(t, fired)}
func TestPublishWithoutBufferIsANoop(t *testing.T) { bus := eventbus.New() bus.Subscribe("claim.approved", func(ctx context.Context, p any) error { return nil }) require.NotPanics(t, func() { eventbus.Publish(context.Background(), "claim.approved", "CLM-1") })}
func TestFlushStopsOnHandlerError(t *testing.T) { bus := eventbus.New() bus.Subscribe("x", func(ctx context.Context, p any) error { return context.Canceled })
ctx, buf := eventbus.WithBuffer(context.Background()) eventbus.Publish(ctx, "x", nil) require.ErrorIs(t, buf.Flush(ctx, bus), context.Canceled)}- Step 2: Run the test to verify it fails
Run: go test ./internal/kernel/eventbus/... -v
Expected: FAIL — package does not exist.
- Step 3: Write the implementation
internal/kernel/eventbus/eventbus.go:
// Package eventbus delivers in-process domain events.//// Events are buffered for the lifetime of a transaction and dispatched only// after it commits. A listener that reacts to a change which then rolls back// would act on something that never happened — a notification about a claim// that was never approved is worse than no notification.package eventbus
import ( "context" "sync")
type Handler func(ctx context.Context, payload any) error
type Bus struct { mu sync.RWMutex handlers map[string][]Handler}
func New() *Bus { return &Bus{handlers: map[string][]Handler{}} }
func (b *Bus) Subscribe(kind string, h Handler) { b.mu.Lock() defer b.mu.Unlock() b.handlers[kind] = append(b.handlers[kind], h)}
type event struct { kind string payload any}
type Buffer struct { mu sync.Mutex events []event}
type ctxKey struct{}
// WithBuffer attaches a buffer to ctx. db.InTx does this, so module code simply// calls Publish.func WithBuffer(ctx context.Context) (context.Context, *Buffer) { buf := &Buffer{} return context.WithValue(ctx, ctxKey{}, buf), buf}
// Publish records an event against the transaction's buffer. Outside a// transaction it is a no-op rather than an error: a caller with no buffer has// nothing to commit, so there is nothing to announce.func Publish(ctx context.Context, kind string, payload any) { buf, ok := ctx.Value(ctxKey{}).(*Buffer) if !ok { return } buf.mu.Lock() defer buf.mu.Unlock() buf.events = append(buf.events, event{kind: kind, payload: payload})}
// Flush dispatches every buffered event in publish order, stopping at the first// handler error.func (buf *Buffer) Flush(ctx context.Context, b *Bus) error { buf.mu.Lock() pending := buf.events buf.events = nil buf.mu.Unlock()
for _, e := range pending { b.mu.RLock() hs := append([]Handler(nil), b.handlers[e.kind]...) b.mu.RUnlock() for _, h := range hs { if err := h(ctx, e.payload); err != nil { return err } } } return nil}- Step 4: Wire the buffer into InTx
Replace the body of db.(*DB).InTx in internal/kernel/db/db.go:
func (d *DB) InTx(ctx context.Context, fn func(ctx context.Context, tx pgx.Tx) error) error { orgID, ok := tenancy.OrgFromContext(ctx) if !ok { return ErrNoTenant }
txCtx, buf := eventbus.WithBuffer(ctx)
tx, err := d.pool.Begin(txCtx) if err != nil { return fmt.Errorf("begin: %w", err) } defer func() { _ = tx.Rollback(txCtx) }()
if _, err := tx.Exec(txCtx, "SELECT set_config('app.current_org', $1, true)", orgID.String()); err != nil { return fmt.Errorf("set tenant: %w", err) }
if err := fn(txCtx, tx); err != nil { return err } if err := tx.Commit(txCtx); err != nil { return err } return buf.Flush(ctx, d.bus)}fn already took a context (Task 6), so no call site changes — it now simply receives
the buffered one. Publishing inside fn must use that context, not the outer ctx.
Add a bus *eventbus.Bus field to DB, set it in Open via a new parameter, and expose func (d *DB) Bus() *eventbus.Bus:
func Open(ctx context.Context, dsn string, bus *eventbus.Bus) (*DB, error) { pool, err := pgxpool.New(ctx, dsn) if err != nil { return nil, fmt.Errorf("open pool: %w", err) } if err := pool.Ping(ctx); err != nil { pool.Close() return nil, fmt.Errorf("ping: %w", err) } if bus == nil { bus = eventbus.New() } return &DB{pool: pool, bus: bus}, nil}
func (d *DB) Bus() *eventbus.Bus { return d.bus }Then update the three existing two-argument call sites to pass a bus:
internal/kernel/db/intx_test.go— three occurrences ofdb.Open(context.Background(), h.AppDSN)internal/kernel/audit/audit_test.go— two occurrences of the same
All become db.Open(context.Background(), h.AppDSN, eventbus.New()), with
"github.com/mcom/erp/internal/kernel/eventbus" added to each file’s imports.
Run go build ./... && go vet ./... to confirm none were missed.
- Step 5: Write the integration test for commit-gating
Append to internal/kernel/db/intx_test.go:
func TestEventsFireOnlyAfterCommit(t *testing.T) { h := testdb.Start(t) org := seedOrg(t, h, "Alpha")
bus := eventbus.New() var fired []string bus.Subscribe("org.touched", func(ctx context.Context, p any) error { fired = append(fired, p.(string)) return nil })
d, err := db.Open(context.Background(), h.AppDSN, bus) require.NoError(t, err) t.Cleanup(d.Close)
ctx := tenancy.WithOrg(context.Background(), org)
// Aborted transaction: nothing fires. _ = d.InTx(ctx, func(txCtx context.Context, tx pgx.Tx) error { eventbus.Publish(txCtx, "org.touched", "aborted") return pgx.ErrTxClosed }) require.Empty(t, fired)
// Committed transaction: fires once. require.NoError(t, d.InTx(ctx, func(txCtx context.Context, tx pgx.Tx) error { eventbus.Publish(txCtx, "org.touched", "committed") return nil })) require.Equal(t, []string{"committed"}, fired)}txCtx is the context InTx derived and put the buffer on. Publishing to the outer ctx
would find no buffer and silently do nothing — which is why fn takes a context.
- Step 6: Run all kernel tests
Run: go test ./internal/kernel/... -v
Expected: PASS across clock, money, errors, db, config, audit, eventbus.
- Step 7: Commit
git add internal/kernelgit commit -m "feat(kernel): transactional event buffer that dispatches after commit"Task 11: platform/numbering — gapless references from a locked row
Section titled “Task 11: platform/numbering — gapless references from a locked row”Files:
- Create:
db/migrations/00004_number_sequence.sql - Create:
internal/platform/numbering/numbering.go - Test:
internal/platform/numbering/numbering_test.go
Interfaces:
-
Consumes:
db.InTx,tenancy -
Produces:
numbering.Allocate(ctx context.Context, tx pgx.Tx, scope string, period int) (int64, error)numbering.Format(prefix string, period int, n int64) string→TRVA-2026-0033
-
Step 1: Write the migration
db/migrations/00004_number_sequence.sql:
-- +goose UpCREATE TABLE number_sequence ( org_id UUID NOT NULL, scope TEXT NOT NULL, period INT NOT NULL, next_number BIGINT NOT NULL, PRIMARY KEY (org_id, scope, period));
ALTER TABLE number_sequence ENABLE ROW LEVEL SECURITY;ALTER TABLE number_sequence FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON number_sequence USING (org_id = current_setting('app.current_org', true)::uuid);
GRANT SELECT, INSERT, UPDATE ON number_sequence TO erp_app;
-- +goose DownDROP TABLE number_sequence;- Step 2: Write the failing test
internal/platform/numbering/numbering_test.go:
package numbering_test
import ( "context" "testing"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/db" "github.com/mcom/erp/internal/kernel/db/testdb" "github.com/mcom/erp/internal/kernel/eventbus" "github.com/mcom/erp/internal/kernel/tenancy" "github.com/mcom/erp/internal/platform/numbering")
func setup(t *testing.T) (*db.DB, context.Context) { t.Helper() h := testdb.Start(t) org := uuid.New() _, err := h.Admin.Exec(context.Background(), `INSERT INTO organization (id, org_id, name) VALUES ($1,$1,'Alpha')`, org) require.NoError(t, err)
d, err := db.Open(context.Background(), h.AppDSN, eventbus.New()) require.NoError(t, err) t.Cleanup(d.Close) return d, tenancy.WithOrg(context.Background(), org)}
func TestAllocateStartsAtOneAndIncrements(t *testing.T) { d, ctx := setup(t)
var got []int64 for i := 0; i < 3; i++ { require.NoError(t, d.InTx(ctx, func(txCtx context.Context, tx pgx.Tx) error { n, err := numbering.Allocate(txCtx, tx, "claim:TRVA", 2026) got = append(got, n) return err })) } require.Equal(t, []int64{1, 2, 3}, got)}
func TestPeriodsAreIndependent(t *testing.T) { d, ctx := setup(t)
var y26, y27 int64 require.NoError(t, d.InTx(ctx, func(txCtx context.Context, tx pgx.Tx) error { var err error y26, err = numbering.Allocate(txCtx, tx, "claim:TRVA", 2026) return err })) require.NoError(t, d.InTx(ctx, func(txCtx context.Context, tx pgx.Tx) error { var err error y27, err = numbering.Allocate(txCtx, tx, "claim:TRVA", 2027) return err })) require.Equal(t, int64(1), y26) require.Equal(t, int64(1), y27)}
// The reason this is a locked table row and not a Postgres sequence: a sequence// does not roll back, so a failed create leaves a permanent gap — and the first// question anybody asks of a gap in a reference series is what was deleted.func TestAbortedTransactionReturnsTheNumber(t *testing.T) { d, ctx := setup(t)
_ = d.InTx(ctx, func(txCtx context.Context, tx pgx.Tx) error { if _, err := numbering.Allocate(txCtx, tx, "claim:TRVA", 2026); err != nil { return err } return pgx.ErrTxClosed })
var n int64 require.NoError(t, d.InTx(ctx, func(txCtx context.Context, tx pgx.Tx) error { var err error n, err = numbering.Allocate(txCtx, tx, "claim:TRVA", 2026) return err })) require.Equal(t, int64(1), n, "the aborted allocation must not have consumed a number")}
func TestFormat(t *testing.T) { require.Equal(t, "TRVA-2026-0033", numbering.Format("TRVA", 2026, 33)) require.Equal(t, "TRVA-2026-10000", numbering.Format("TRVA", 2026, 10000))}- Step 3: Run the test to verify it fails
Run: go test ./internal/platform/numbering/... -v
Expected: FAIL — package does not exist.
- Step 4: Write the implementation
internal/platform/numbering/numbering.go:
// Package numbering allocates human-facing and statutory document numbers.//// Allocation is an upsert on a row, inside the transaction that creates the// document. The row lock serializes concurrent creates, and a transaction that// aborts gives the number back.//// A Postgres sequence would do neither: it does not roll back, so a failed// create would leave a permanent gap, and the first question anybody asks of a// gap in a reference series is what was deleted.package numbering
import ( "context" "fmt"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/tenancy")
// Allocate takes the next number for (scope, period) and returns it.//// MUST be called inside the transaction that creates the document.//// `period` is the calendar year for human-facing references — a claimant// reading TRVA-2026-0033 should not have to know when their employer's fiscal// year ends — and the fiscal year for statutory document numbers.func Allocate(ctx context.Context, tx pgx.Tx, scope string, period int) (int64, error) { orgID, ok := tenancy.OrgFromContext(ctx) if !ok { return 0, fmt.Errorf("numbering: no organization in context") }
// Seeds at 2 rather than 1: the row it inserts is the state AFTER this // allocation, and `next_number - 1` is the number just taken. var n int64 err := tx.QueryRow(ctx, ` INSERT INTO number_sequence (org_id, scope, period, next_number) VALUES ($1, $2, $3, 2) ON CONFLICT (org_id, scope, period) DO UPDATE SET next_number = number_sequence.next_number + 1 RETURNING next_number - 1`, orgID, scope, period).Scan(&n) if err != nil { return 0, fmt.Errorf("allocate %s/%d: %w", scope, period, err) } return n, nil}
// Format renders a reference as PREFIX-PERIOD-NNNN, padded to four digits and// growing beyond it rather than wrapping.func Format(prefix string, period int, n int64) string { return fmt.Sprintf("%s-%d-%04d", prefix, period, n)}- Step 5: Run the tests
Run: go test ./internal/platform/numbering/... -v
Expected: PASS, 4 tests.
- Step 6: Commit
git add db/migrations/00004_number_sequence.sql internal/platform/numberinggit commit -m "feat(platform): gapless numbering from a locked sequence row"Task 12: platform/registry — the org-configurable lookup primitive
Section titled “Task 12: platform/registry — the org-configurable lookup primitive”Files:
- Create:
db/migrations/00005_registry.sql - Create:
internal/platform/registry/registry.go - Test:
internal/platform/registry/registry_test.go
Interfaces:
-
Consumes:
errors -
Produces:
registry.Rowinterface:RowOrgID() *uuid.UUID,RowActive() boolregistry.AssertEditable(r Row) error— refuses edits to seeded global rowsregistry.VisibleSQL(table string) string— the visibility predicate every registry query uses- Migration template (documented in the file header) for a registry table
-
Step 1: Write the migration establishing the pattern
db/migrations/00005_registry.sql creates the first registry — expense categories — as the worked example every later registry copies:
-- +goose Up-- The registry pattern. Every org-configurable lookup table looks like this.---- org_id NULL means a globally seeded row, visible to every organization and-- editable by none of them. A non-NULL org_id is that organization's own row,-- which shadows a global of the same key.CREATE TABLE expense_category ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID, key TEXT NOT NULL, is_active BOOLEAN NOT NULL DEFAULT TRUE, sort_order INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted_at TIMESTAMPTZ);
-- Two partial expression indexes, not one plain UNIQUE. Postgres treats NULL-- org_id values as distinct, so a plain constraint would let two global rows-- both be keyed "cash"; and keys are compared case-insensitively.CREATE UNIQUE INDEX expense_category_global_key ON expense_category (lower(key)) WHERE org_id IS NULL AND deleted_at IS NULL;CREATE UNIQUE INDEX expense_category_org_key ON expense_category (org_id, lower(key)) WHERE org_id IS NOT NULL AND deleted_at IS NULL;
ALTER TABLE expense_category ENABLE ROW LEVEL SECURITY;ALTER TABLE expense_category FORCE ROW LEVEL SECURITY;
-- Registries are the one shape where the policy admits NULL org_id: a global-- row belongs to everyone.CREATE POLICY org_isolation ON expense_category USING (org_id IS NULL OR org_id = current_setting('app.current_org', true)::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON expense_category TO erp_app;
-- +goose DownDROP TABLE expense_category;Add expense_category handling to db/rls_exempt.txt? No — it has org_id and a policy, so it passes Task 7 unchanged. Do not add it.
- Step 2: Write the failing test
internal/platform/registry/registry_test.go:
package registry_test
import ( "context" "testing"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/db" "github.com/mcom/erp/internal/kernel/db/testdb" "github.com/mcom/erp/internal/kernel/eventbus" "github.com/mcom/erp/internal/kernel/tenancy" "github.com/mcom/erp/internal/platform/registry")
type row struct { orgID *uuid.UUID active bool}
func (r row) RowOrgID() *uuid.UUID { return r.orgID }func (r row) RowActive() bool { return r.active }
func TestAssertEditableRefusesGlobalRows(t *testing.T) { err := registry.AssertEditable(row{orgID: nil, active: true}) require.Error(t, err) require.Contains(t, err.Error(), "seeded")}
func TestAssertEditableAllowsOrgRows(t *testing.T) { id := uuid.New() require.NoError(t, registry.AssertEditable(row{orgID: &id, active: true}))}
func TestGlobalsAreVisibleToEveryOrg(t *testing.T) { h := testdb.Start(t) org := uuid.New() _, err := h.Admin.Exec(context.Background(), `INSERT INTO organization (id, org_id, name) VALUES ($1,$1,'Alpha')`, org) require.NoError(t, err) _, err = h.Admin.Exec(context.Background(), `INSERT INTO expense_category (org_id, key) VALUES (NULL, 'meals')`) require.NoError(t, err) _, err = h.Admin.Exec(context.Background(), `INSERT INTO expense_category (org_id, key) VALUES ($1, 'client_gifts')`, org) require.NoError(t, err)
d, err := db.Open(context.Background(), h.AppDSN, eventbus.New()) require.NoError(t, err) t.Cleanup(d.Close)
ctx := tenancy.WithOrg(context.Background(), org) var keys []string require.NoError(t, d.InTx(ctx, func(txCtx context.Context, tx pgx.Tx) error { rows, err := tx.Query(txCtx, `SELECT key FROM expense_category ORDER BY key`) if err != nil { return err } defer rows.Close() for rows.Next() { var k string if err := rows.Scan(&k); err != nil { return err } keys = append(keys, k) } return rows.Err() })) require.Equal(t, []string{"client_gifts", "meals"}, keys)}
func TestGlobalKeysAreUniqueCaseInsensitively(t *testing.T) { h := testdb.Start(t) _, err := h.Admin.Exec(context.Background(), `INSERT INTO expense_category (org_id, key) VALUES (NULL, 'Cash')`) require.NoError(t, err) _, err = h.Admin.Exec(context.Background(), `INSERT INTO expense_category (org_id, key) VALUES (NULL, 'cash')`) require.Error(t, err, "two globals must not share a key differing only in case")}- Step 3: Run the test to verify it fails
Run: go test ./internal/platform/registry/... -v
Expected: FAIL — package does not exist.
- Step 4: Write the implementation
internal/platform/registry/registry.go:
// Package registry holds the rules every org-configurable lookup table shares,// in one place so that adding a fourth registry cannot quietly get one of them// wrong.//// The rules://// 1. Visibility — global rows (org_id IS NULL) plus the organization's own.// An org row shadows a global with the same key.// 2. Uniqueness — case-insensitive, per scope, as two partial expression// indexes. A single UNIQUE constraint is wrong: Postgres treats NULL// org_id values as distinct, so two globals could both be named "Cash".// 3. Editability — a seeded global row is never editable by an organization.// An org that wants a variant copies it.// 4. Retire, never delete — `is_active = false` is the retirement. That is// distinct from `deleted_at`, which is for a row filed in error and is// refused once anything references it.// 5. Labels are translation rows, never a column on the registry.//// Migration template: see db/migrations/00005_registry.sql, which creates// expense_category as the worked example.package registry
import ( "fmt"
"github.com/google/uuid")
// Row is the minimum a registry row must expose for the guards below to judge it.type Row interface { RowOrgID() *uuid.UUID RowActive() bool}
// AssertEditable refuses an edit to a globally seeded row.//// An organization wanting a variant of a seeded row copies it into its own// scope; letting it edit the global would change the row for every tenant.func AssertEditable(r Row) error { if r.RowOrgID() == nil { return fmt.Errorf("this is a seeded row and cannot be edited; copy it instead") } return nil}
// VisibleSQL is the visibility predicate every registry query uses. Written// once so a new registry cannot get rule 1 wrong.func VisibleSQL(table string) string { return fmt.Sprintf( "(%[1]s.org_id IS NULL OR %[1]s.org_id = current_setting('app.current_org', true)::uuid) "+ "AND %[1]s.deleted_at IS NULL", table)}- Step 5: Run the tests
Run: go test ./internal/platform/registry/... -v
Expected: PASS, 4 tests.
- Step 6: Commit
git add db/migrations/00005_registry.sql internal/platform/registrygit commit -m "feat(platform): org-configurable registry rules in one place"Task 13: platform/i18n — translation rows and locale resolution
Section titled “Task 13: platform/i18n — translation rows and locale resolution”Files:
- Create:
db/migrations/00006_translation.sql - Create:
internal/platform/i18n/i18n.go - Test:
internal/platform/i18n/i18n_test.go
Interfaces:
-
Consumes:
tenancy -
Produces:
i18n.ParseAcceptLanguage(header string) stringi18n.WithLocale(ctx, locale string) context.Context/i18n.FromContext(ctx) stringi18n.Load(ctx, tx, kind string, ids []uuid.UUID, field string) (map[uuid.UUID]string, error)i18n.DefaultLocale = "en",i18n.Supported = []string{"en", "zh"}
-
Step 1: Write the migration
db/migrations/00006_translation.sql:
-- +goose Up-- Every user-facing name is a row here, never a VARCHAR on the entity.-- A label change is then a translation change, not a schema change, and seeded-- data can ship en and zh together.CREATE TABLE translation ( org_id UUID NOT NULL, entity_kind TEXT NOT NULL, entity_id UUID NOT NULL, field TEXT NOT NULL, locale TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (org_id, entity_kind, entity_id, field, locale));
ALTER TABLE translation ENABLE ROW LEVEL SECURITY;ALTER TABLE translation FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON translation USING (org_id = current_setting('app.current_org', true)::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON translation TO erp_app;
-- +goose DownDROP TABLE translation;- Step 2: Write the failing test
internal/platform/i18n/i18n_test.go:
package i18n_test
import ( "context" "testing"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/db" "github.com/mcom/erp/internal/kernel/db/testdb" "github.com/mcom/erp/internal/kernel/eventbus" "github.com/mcom/erp/internal/kernel/tenancy" "github.com/mcom/erp/internal/platform/i18n")
func TestParseAcceptLanguage(t *testing.T) { require.Equal(t, "zh", i18n.ParseAcceptLanguage("zh-CN,zh;q=0.9,en;q=0.8")) require.Equal(t, "en", i18n.ParseAcceptLanguage("en-GB")) require.Equal(t, "en", i18n.ParseAcceptLanguage("")) // An unsupported language falls back rather than returning something with // no translations behind it. require.Equal(t, "en", i18n.ParseAcceptLanguage("de-DE"))}
func TestLoadReturnsRequestedLocale(t *testing.T) { d, ctx, org := setupI18n(t) id := uuid.New() seedTranslation(t, d, ctx, org, id, "en", "Meals") seedTranslation(t, d, ctx, org, id, "zh", "餐饮")
got := load(t, d, i18n.WithLocale(ctx, "zh"), id) require.Equal(t, "餐饮", got[id])}
// A missing translation must fall back to English rather than render blank.func TestLoadFallsBackToDefaultLocale(t *testing.T) { d, ctx, org := setupI18n(t) id := uuid.New() seedTranslation(t, d, ctx, org, id, "en", "Meals")
got := load(t, d, i18n.WithLocale(ctx, "zh"), id) require.Equal(t, "Meals", got[id])}
func setupI18n(t *testing.T) (*db.DB, context.Context, uuid.UUID) { t.Helper() h := testdb.Start(t) org := uuid.New() _, err := h.Admin.Exec(context.Background(), `INSERT INTO organization (id, org_id, name) VALUES ($1,$1,'Alpha')`, org) require.NoError(t, err) d, err := db.Open(context.Background(), h.AppDSN, eventbus.New()) require.NoError(t, err) t.Cleanup(d.Close) return d, tenancy.WithOrg(context.Background(), org), org}
func seedTranslation(t *testing.T, d *db.DB, ctx context.Context, org, id uuid.UUID, locale, value string) { t.Helper() require.NoError(t, d.InTx(ctx, func(txCtx context.Context, tx pgx.Tx) error { _, err := tx.Exec(txCtx, ` INSERT INTO translation (org_id, entity_kind, entity_id, field, locale, value) VALUES ($1,'expense_category',$2,'name',$3,$4)`, org, id, locale, value) return err }))}
func load(t *testing.T, d *db.DB, ctx context.Context, id uuid.UUID) map[uuid.UUID]string { t.Helper() var out map[uuid.UUID]string require.NoError(t, d.InTx(ctx, func(txCtx context.Context, tx pgx.Tx) error { var err error out, err = i18n.Load(txCtx, tx, "expense_category", []uuid.UUID{id}, "name") return err })) return out}- Step 3: Run the test to verify it fails
Run: go test ./internal/platform/i18n/... -v
Expected: FAIL — package does not exist.
- Step 4: Write the implementation
internal/platform/i18n/i18n.go:
// Package i18n resolves the caller's locale and loads translated names.//// Every user-facing name is a translation row. A VARCHAR on the entity means a// second language is a schema change, and it means seeded data can only ever// ship in one language — which then has to be backfilled for every tenant.package i18n
import ( "context" "fmt" "strings"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/tenancy")
const DefaultLocale = "en"
var Supported = []string{"en", "zh"}
type ctxKey struct{}
func WithLocale(ctx context.Context, locale string) context.Context { return context.WithValue(ctx, ctxKey{}, locale)}
func FromContext(ctx context.Context) string { if l, ok := ctx.Value(ctxKey{}).(string); ok { return l } return DefaultLocale}
// ParseAcceptLanguage takes the first tag, strips any region, and falls back to// the default when the language is not one we have translations for.func ParseAcceptLanguage(header string) string { if header == "" { return DefaultLocale } first := strings.TrimSpace(strings.Split(header, ",")[0]) first = strings.TrimSpace(strings.Split(first, ";")[0]) lang := strings.ToLower(strings.Split(first, "-")[0]) for _, s := range Supported { if s == lang { return lang } } return DefaultLocale}
// Load returns the translated field for each id, in the context's locale,// falling back to DefaultLocale per row where the locale has no translation.func Load(ctx context.Context, tx pgx.Tx, kind string, ids []uuid.UUID, field string) (map[uuid.UUID]string, error) { orgID, ok := tenancy.OrgFromContext(ctx) if !ok { return nil, fmt.Errorf("i18n: no organization in context") } locale := FromContext(ctx)
rows, err := tx.Query(ctx, ` SELECT DISTINCT ON (entity_id) entity_id, value FROM translation WHERE org_id = $1 AND entity_kind = $2 AND field = $3 AND entity_id = ANY($4) AND locale IN ($5, $6) ORDER BY entity_id, (locale = $5) DESC`, orgID, kind, field, ids, locale, DefaultLocale) if err != nil { return nil, fmt.Errorf("i18n load: %w", err) } defer rows.Close()
out := make(map[uuid.UUID]string, len(ids)) for rows.Next() { var id uuid.UUID var value string if err := rows.Scan(&id, &value); err != nil { return nil, err } out[id] = value } return out, rows.Err()}The ORDER BY (locale = $5) DESC with DISTINCT ON (entity_id) is what makes the fallback per row: the requested locale sorts first where it exists, and the default wins where it does not.
- Step 5: Run the tests
Run: go test ./internal/platform/i18n/... -v
Expected: PASS, 3 tests.
- Step 6: Commit
git add db/migrations/00006_translation.sql internal/platform/i18ngit commit -m "feat(platform): translation rows with per-row locale fallback"Task 14: The codegen pipelines — sqlc, OpenAPI and shared vocabulary
Section titled “Task 14: The codegen pipelines — sqlc, OpenAPI and shared vocabulary”Files:
- Create:
sqlc.yaml,db/queries/organization.sql - Create:
api/openapi.yaml,oapi-codegen.yaml - Create:
constants.yaml,tools/vocabgen/main.go - Test:
internal/kernel/vocab/vocab_test.go
Interfaces:
-
Consumes: nothing
-
Produces:
internal/gen/sqlc— generated query methodsinternal/gen/api— generated server interfaces and typesinternal/kernel/vocabandpackages/contracts/vocab.ts— generated vocabulary
-
Step 1: Configure sqlc and write one query to prove the pipeline
sqlc.yaml:
version: "2"sql: - engine: postgresql schema: db/migrations queries: db/queries gen: go: package: sqlc out: internal/gen/sqlc sql_package: pgx/v5 emit_pointers_for_null_types: truedb/queries/organization.sql:
-- name: GetOrganization :oneSELECT id, org_id, name, created_at FROM organization WHERE id = $1;
-- name: ListOrganizations :manySELECT id, org_id, name, created_at FROM organization ORDER BY name;Run: sqlc generate && go build ./...
Expected: internal/gen/sqlc appears and compiles.
- Step 2: Write the OpenAPI contract with the vocabulary enums
api/openapi.yaml:
openapi: 3.0.3info: title: mcom ERP API version: 0.1.0servers: - url: /v1paths: /health: get: operationId: getHealth summary: Liveness probe responses: '200': description: The service is up content: application/json: schema: $ref: '#/components/schemas/Health'components: schemas: Health: type: object required: [status, version] properties: status: { type: string, enum: [ok] } version: { type: string } Money: type: string description: A decimal amount as a string. Never a JSON number. example: "1234.5600" Semantic: type: string description: The closed set of workflow state semantics the ledger reacts to. enum: [draft, pending, accrual, payout, acknowledged, dispute, rejected, cancelled, custom] GateKind: type: string enum: [reporter, target, source, advance] GranteeKind: type: string enum: [member, superior, position, unit_head, levels_up] SettlementBehaviour: type: string enum: [external, petty_cash] Problem: type: object required: [type, title, status] properties: type: { type: string } title: { type: string } status: { type: integer } detail: { type: string } instance: { type: string }oapi-codegen.yaml:
package: apioutput: internal/gen/api/api.gen.gogenerate: gin-server: true models: true strict-server: trueRun:
go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latestoapi-codegen -config oapi-codegen.yaml api/openapi.yamlgo build ./...Expected: internal/gen/api/api.gen.go appears and compiles.
- Step 3: Write the off-wire vocabulary source and its generator
constants.yaml — vocabulary both sides need that never crosses the wire:
permissions: - expenses.claim.create - expenses.claim.approve - expenses.claim.verify - accounting.journal.post - accounting.account.manage - pettycash.float.disburse - workflow.definition.publish - org.profile.manageerrorTypes: - validation - not-found - conflict - forbidden - unauthorized - internaltools/vocabgen/main.go:
// Command vocabgen turns constants.yaml into Go and TypeScript sources, so the// two languages cannot drift. Generated files are committed: a vocabulary// change is then a diff on both sides in the same commit.package main
import ( "fmt" "os" "strings"
"gopkg.in/yaml.v3")
type spec struct { Permissions []string `yaml:"permissions"` ErrorTypes []string `yaml:"errorTypes"`}
func main() { raw, err := os.ReadFile("constants.yaml") must(err) var s spec must(yaml.Unmarshal(raw, &s))
must(os.MkdirAll("internal/kernel/vocab", 0o755)) must(os.WriteFile("internal/kernel/vocab/vocab.gen.go", []byte(goSource(s)), 0o644))
must(os.MkdirAll("packages/contracts", 0o755)) must(os.WriteFile("packages/contracts/vocab.ts", []byte(tsSource(s)), 0o644))}
func goSource(s spec) string { var b strings.Builder b.WriteString("// Code generated by tools/vocabgen. DO NOT EDIT.\n\npackage vocab\n\n") b.WriteString("var Permissions = []string{\n") for _, p := range s.Permissions { fmt.Fprintf(&b, "\t%q,\n", p) } b.WriteString("}\n\nvar ErrorTypes = []string{\n") for _, e := range s.ErrorTypes { fmt.Fprintf(&b, "\t%q,\n", e) } b.WriteString("}\n") return b.String()}
func tsSource(s spec) string { var b strings.Builder b.WriteString("// Code generated by tools/vocabgen. DO NOT EDIT.\n\n") b.WriteString("export const PERMISSIONS = [\n") for _, p := range s.Permissions { fmt.Fprintf(&b, " %q,\n", p) } b.WriteString("] as const;\nexport type Permission = (typeof PERMISSIONS)[number];\n\n") b.WriteString("export const ERROR_TYPES = [\n") for _, e := range s.ErrorTypes { fmt.Fprintf(&b, " %q,\n", e) } b.WriteString("] as const;\nexport type ErrorType = (typeof ERROR_TYPES)[number];\n") return b.String()}
func must(err error) { if err != nil { panic(err) }}Run:
go get gopkg.in/yaml.v3go run ./tools/vocabgen- Step 4: Write the drift test for generated vocabulary
internal/kernel/vocab/vocab_test.go:
package vocab_test
import ( "os" "os/exec" "path/filepath" "runtime" "testing"
"github.com/stretchr/testify/require")
// Generated files are committed. If someone edits constants.yaml without// regenerating, this fails rather than letting Go and TypeScript disagree.func TestGeneratedVocabularyIsUpToDate(t *testing.T) { root := repoRoot(t) path := filepath.Join(root, "internal", "kernel", "vocab", "vocab.gen.go")
before, err := os.ReadFile(path) require.NoError(t, err)
cmd := exec.Command("go", "run", "./tools/vocabgen") cmd.Dir = root out, err := cmd.CombinedOutput() require.NoError(t, err, string(out))
after, err := os.ReadFile(path) require.NoError(t, err) require.Equal(t, string(before), string(after), "constants.yaml changed without regenerating; run `make generate`")}
func repoRoot(t *testing.T) string { t.Helper() _, file, _, ok := runtime.Caller(0) require.True(t, ok) return filepath.Join(filepath.Dir(file), "..", "..", "..")}- Step 5: Run the tests and commit
Run: go test ./internal/kernel/vocab/... -v && go build ./...
Expected: PASS.
git add sqlc.yaml db/queries api/openapi.yaml oapi-codegen.yaml constants.yaml \ tools/vocabgen internal/gen internal/kernel/vocab packages/contractsgit commit -m "build: sqlc, OpenAPI and shared vocabulary codegen pipelines"Task 15: The API bootstrap — middleware chain and readiness
Section titled “Task 15: The API bootstrap — middleware chain and readiness”Files:
- Create:
internal/kernel/httpx/middleware.go - Create:
internal/kernel/httpx/server.go - Create:
cmd/api/main.go - Create:
cmd/worker/main.go - Test:
internal/kernel/httpx/middleware_test.go
Interfaces:
- Consumes: everything built above
- Produces:
httpx.RequestID(),httpx.Locale(),httpx.Tenant(),httpx.Errors(),httpx.AccessLog()Gin middlewarehttpx.NewRouter(deps Deps) *gin.Engine
Note on the three logs (spec 20.1.7): audit_log is Task 9. Request telemetry is
this task’s AccessLog — structured slog lines, not a table, because operational
telemetry belongs in the log pipeline rather than the tenant database. The activity
log (the user-facing “who did what” feed) ships in Plan 2 alongside the first
user-facing entity; there is nothing to render a feed about yet.
- Step 1: Write the failing middleware test
internal/kernel/httpx/middleware_test.go:
package httpx_test
import ( "bytes" "context" "encoding/json" "log/slog" "net/http" "net/http/httptest" "testing"
"github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/stretchr/testify/require"
apperr "github.com/mcom/erp/internal/kernel/errors" "github.com/mcom/erp/internal/kernel/httpx" "github.com/mcom/erp/internal/kernel/tenancy" "github.com/mcom/erp/internal/platform/i18n")
func TestTenantMiddlewareRejectsMissingHeader(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() r.Use(httpx.Errors(), httpx.Tenant()) r.GET("/x", func(c *gin.Context) { c.Status(http.StatusOK) })
w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/x", nil))
require.Equal(t, http.StatusUnauthorized, w.Code) var p apperr.Problem require.NoError(t, json.Unmarshal(w.Body.Bytes(), &p)) require.Equal(t, "https://errors.mcom.erp/unauthorized", p.Type)}
func TestTenantMiddlewarePutsOrgOnContext(t *testing.T) { gin.SetMode(gin.TestMode) org := uuid.New()
var seen uuid.UUID r := gin.New() r.Use(httpx.Errors(), httpx.Tenant()) r.GET("/x", func(c *gin.Context) { id, ok := tenancy.OrgFromContext(c.Request.Context()) require.True(t, ok) seen = id c.Status(http.StatusOK) })
req := httptest.NewRequest(http.MethodGet, "/x", nil) req.Header.Set("X-Org-Id", org.String()) w := httptest.NewRecorder() r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, org, seen)}
func TestLocaleMiddlewareResolvesAcceptLanguage(t *testing.T) { gin.SetMode(gin.TestMode) var seen string r := gin.New() r.Use(httpx.Locale()) r.GET("/x", func(c *gin.Context) { seen = i18n.FromContext(c.Request.Context()) c.Status(http.StatusOK) })
req := httptest.NewRequest(http.MethodGet, "/x", nil) req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9") r.ServeHTTP(httptest.NewRecorder(), req)
require.Equal(t, "zh", seen)}
func TestErrorsMiddlewareRendersProblemJSON(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() r.Use(httpx.Errors()) r.GET("/x", func(c *gin.Context) { _ = c.Error(apperr.New(apperr.KindNotFound, "no such claim")) })
w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/x", nil))
require.Equal(t, http.StatusNotFound, w.Code) require.Equal(t, "application/problem+json", w.Header().Get("Content-Type"))}
func TestAccessLogEmitsOneLinePerRequest(t *testing.T) { gin.SetMode(gin.TestMode) var buf bytes.Buffer logger := slog.New(slog.NewJSONHandler(&buf, nil))
r := gin.New() r.Use(httpx.RequestID(), httpx.AccessLog(logger)) r.GET("/x", func(c *gin.Context) { c.Status(http.StatusOK) })
r.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/x", nil))
var line map[string]any require.NoError(t, json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &line)) require.Equal(t, "request", line["msg"]) require.Equal(t, float64(200), line["status"]) require.NotEmpty(t, line["request_id"])}
func TestRequestIDIsEchoed(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() r.Use(httpx.RequestID()) r.GET("/x", func(c *gin.Context) { c.Status(http.StatusOK) })
w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/x", nil)) require.NotEmpty(t, w.Header().Get("X-Request-Id")) _ = context.Background()}- Step 2: Run the test to verify it fails
Run: go test ./internal/kernel/httpx/... -v
Expected: FAIL — package does not exist.
- Step 3: Write the middleware
internal/kernel/httpx/middleware.go:
// Package httpx holds the HTTP edge: the middleware chain and the router.package httpx
import ( "log/slog" "net/http" "time"
"github.com/gin-gonic/gin" "github.com/google/uuid"
apperr "github.com/mcom/erp/internal/kernel/errors" "github.com/mcom/erp/internal/kernel/tenancy" "github.com/mcom/erp/internal/platform/i18n")
const HeaderRequestID = "X-Request-Id"const HeaderOrgID = "X-Org-Id"
func RequestID() gin.HandlerFunc { return func(c *gin.Context) { id := c.GetHeader(HeaderRequestID) if id == "" { id = uuid.NewString() } c.Writer.Header().Set(HeaderRequestID, id) c.Set("request_id", id) c.Next() }}
func Locale() gin.HandlerFunc { return func(c *gin.Context) { locale := i18n.ParseAcceptLanguage(c.GetHeader("Accept-Language")) c.Request = c.Request.WithContext(i18n.WithLocale(c.Request.Context(), locale)) c.Next() }}
// Tenant resolves the active organization. Phase 0 trusts the header; Plan 2// replaces this body with membership validation against the JWT. The context// key and the failure shape do not change, so nothing downstream moves.func Tenant() gin.HandlerFunc { return func(c *gin.Context) { raw := c.GetHeader(HeaderOrgID) if raw == "" { _ = c.Error(apperr.New(apperr.KindUnauthorized, "X-Org-Id is required")) c.Abort() return } id, err := uuid.Parse(raw) if err != nil { _ = c.Error(apperr.New(apperr.KindValidation, "X-Org-Id must be a UUID")) c.Abort() return } c.Request = c.Request.WithContext(tenancy.WithOrg(c.Request.Context(), id)) c.Next() }}
// Errors renders any error a handler recorded as application/problem+json.// Handlers never write an error body themselves.func Errors() gin.HandlerFunc { return func(c *gin.Context) { c.Next() if len(c.Errors) == 0 { return } err := c.Errors[0].Err p, ok := err.(*apperr.Problem) if !ok { p = apperr.FromPg(err) } p.Instance = c.GetString("request_id") c.Header("Content-Type", "application/problem+json") c.AbortWithStatusJSON(p.Status, p) }}
// AccessLog emits one structured line per request. Operational telemetry, not// the audit trail (which commits with its change) and not the activity feed// (which is user-facing). Keeping the three separate is what stops one table// being too noisy to audit and too slow to render.func AccessLog(logger *slog.Logger) gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() c.Next()
attrs := []any{ "method", c.Request.Method, "path", c.FullPath(), "status", c.Writer.Status(), "duration_ms", time.Since(start).Milliseconds(), "request_id", c.GetString("request_id"), "ip", c.ClientIP(), } if orgID, ok := tenancy.OrgFromContext(c.Request.Context()); ok { attrs = append(attrs, "org_id", orgID.String()) } if c.Writer.Status() >= 500 { logger.Error("request", attrs...) return } logger.Info("request", attrs...) }}
func notFound(c *gin.Context) { c.Header("Content-Type", "application/problem+json") c.AbortWithStatusJSON(http.StatusNotFound, apperr.New(apperr.KindNotFound, "no such route"))}- Step 4: Write the router and entrypoints
internal/kernel/httpx/server.go:
package httpx
import ( "log/slog"
"github.com/gin-gonic/gin"
"github.com/mcom/erp/internal/kernel/db")
type Deps struct { DB *db.DB Logger *slog.Logger Version string}
func NewRouter(deps Deps) *gin.Engine { if deps.Logger == nil { deps.Logger = slog.Default() } r := gin.New() r.Use(gin.Recovery(), RequestID(), AccessLog(deps.Logger), Errors(), Locale()) r.NoRoute(notFound)
// Unauthenticated: probes must work before a tenant exists. r.GET("/v1/health", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok", "version": deps.Version}) })
// Everything else is tenant-scoped. Later plans hang their routes here. api := r.Group("/v1", Tenant()) _ = api
return r}cmd/api/main.go:
package main
import ( "context" "log" "log/slog" "os"
"github.com/mcom/erp/internal/kernel/config" "github.com/mcom/erp/internal/kernel/db" "github.com/mcom/erp/internal/kernel/eventbus" "github.com/mcom/erp/internal/kernel/httpx")
var version = "dev"
func main() { cfg, err := config.Load() if err != nil { log.Fatalf("config: %v", err) }
ctx := context.Background() database, err := db.Open(ctx, cfg.DatabaseURL, eventbus.New()) if err != nil { log.Fatalf("database: %v", err) } defer database.Close()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) r := httpx.NewRouter(httpx.Deps{DB: database, Logger: logger, Version: version}) log.Printf("listening on :%s", cfg.Port) if err := r.Run(":" + cfg.Port); err != nil { log.Fatalf("serve: %v", err) }}cmd/worker/main.go:
package main
import ( "context" "log"
"github.com/mcom/erp/internal/kernel/config" "github.com/mcom/erp/internal/kernel/db" "github.com/mcom/erp/internal/kernel/eventbus")
// The worker shares the API's code but not its HTTP bootstrap, so scheduled// work never competes with request latency. Plan 4 onwards registers jobs here.func main() { cfg, err := config.Load() if err != nil { log.Fatalf("config: %v", err) } database, err := db.Open(context.Background(), cfg.DatabaseURL, eventbus.New()) if err != nil { log.Fatalf("database: %v", err) } defer database.Close()
log.Println("worker started; no jobs registered yet") select {}}- Step 5: Run the tests
Run: go test ./internal/kernel/httpx/... -v
Expected: PASS, 6 tests.
- Step 6: Run the whole suite and build
Run: make lint && make test && make build
Expected: all packages PASS; bin/api, bin/worker, bin/migrate produced.
- Step 7: Commit
git add internal/kernel/httpx cmdgit commit -m "feat(kernel): HTTP middleware chain, router and service entrypoints"Done when
Section titled “Done when”make lint && make test && make buildpasses from a clean checkout.- A new table without
org_id, RLS enabled and forced, and anorg_isolationpolicy fails the build. - A new
os.Getenvcall without a matching.env.exampleentry fails the build. constants.yamledited without regenerating fails the build.GET /v1/healthreturns{"status":"ok"}; any/v1/*route withoutX-Org-Idreturnsapplication/problem+jsonwith 401.- Money round-trips as a JSON string and rejects a JSON number.
- An aborted transaction leaves no audit row, fires no event, and consumes no reference number.
- Every request emits one structured JSON log line carrying request id, status, duration and org.
Next plan: 2026-09-20-phase-1-identity-and-org-structure.md — users, organizations, company profile, roles and permissions, org units, positions and the reporting tree.