Skip to content

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

  • Go module path: github.com/mcom/erp. Go 1.26 minimum (amended 2026-09-22 from 1.23: pressly/goose/v3 requires it). go.mod is the source of truth; CI derives its version from it rather than restating one.
  • Money is NUMERIC(19,4) in Postgres and shopspring/decimal in 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 named org_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 VARCHAR on 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 build targets used by every later task

  • Step 1: Initialise the Go module

Terminal window
cd /Users/limziyang/Documents/repos/mcomtask
go mod init github.com/mcom/erp
mkdir -p cmd/api cmd/worker cmd/migrate internal/kernel internal/platform db/migrations db/queries api web packages
touch internal/kernel/.keep internal/platform/.keep db/migrations/.keep db/queries/.keep api/.keep
  • Step 2: Add the dependencies
Terminal window
go get github.com/gin-gonic/gin@latest
go get github.com/jackc/pgx/v5@latest
go get github.com/shopspring/decimal@latest
go get github.com/pressly/goose/v3@latest
go get github.com/google/uuid@latest
go get github.com/stretchr/testify@latest
go get github.com/testcontainers/testcontainers-go@latest
go get github.com/testcontainers/testcontainers-go/modules/postgres@latest
go 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 .gitignore and .env.example

.gitignore:

bin/
.env
*.test

.env.example — every variable the code reads must appear here; Task 8 enforces it:

# Database
DATABASE_URL=postgres://erp_app:erp@localhost:5432/erp?sslmode=disable
DATABASE_MIGRATE_URL=postgres://postgres:postgres@localhost:5432/erp?sslmode=disable
# HTTP
PORT=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: CI
on: [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.

Terminal window
git add -A
git 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.Pool
    • testdb.Start(t *testing.T) *db.DB — a migrated database per test package
    • testdb.AdminPool(t *testing.T) *pgxpool.Pool — privileged pool for catalog queries
  • Step 1: Write the first migration

db/migrations/00001_init.sql:

-- +goose Up
CREATE 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 Down
DROP 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
Terminal window
git add go.mod go.sum db/migrations cmd/migrate internal/kernel/db
git 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.Clock interface with Now() time.Time
    • clock.System() Clock
    • clock.Fixed(t time.Time) Clock
    • clock.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
Terminal window
git add internal/kernel/clock
git 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.Amount value type
    • money.Parse(s string) (Amount, error)
    • money.MustParse(s string) Amount
    • money.Zero() Amount
    • (Amount) String() string, Add, Sub, Neg, IsNegative, IsZero, Cmp
    • (Amount) MarshalJSON, (*Amount) UnmarshalJSON — string on the wire
    • (Amount) Value() / (*Amount) Scan() for NUMERIC(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
Terminal window
git add internal/kernel/money
git 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.Problem struct with Type, Title, Status, Detail, Instance
    • errors.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
Terminal window
git add internal/kernel/errors
git 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 — add InTx
  • Test: internal/kernel/db/intx_test.go

Interfaces:

  • Consumes: db.Open, testdb.Start

  • Produces:

    • tenancy.WithOrg(ctx context.Context, orgID uuid.UUID) context.Context
    • tenancy.OrgFromContext(ctx context.Context) (uuid.UUID, bool)
    • db.(*DB).InTx(ctx context.Context, fn func(ctx context.Context, tx pgx.Tx) error) error — fn receives 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 Up
CREATE 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 Down
DROP 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
Terminal window
git add db/migrations/00002_organization.sql internal/kernel/tenancy internal/kernel/db
git 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
Terminal window
git add db/rls_exempt.txt internal/kernel/db/rlscoverage_test.go
git 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
Terminal window
git add internal/kernel/config
git 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 Up
CREATE 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 Down
DROP 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
Terminal window
git add db/migrations/00003_audit_log.sql internal/kernel/audit
git 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.Bus with Subscribe(kind string, h Handler) and dispatch
    • eventbus.Handler func(ctx context.Context, payload any) error
    • eventbus.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 of db.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
Terminal window
git add internal/kernel
git 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 Up
CREATE 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 Down
DROP 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
Terminal window
git add db/migrations/00004_number_sequence.sql internal/platform/numbering
git 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.Row interface: RowOrgID() *uuid.UUID, RowActive() bool
    • registry.AssertEditable(r Row) error — refuses edits to seeded global rows
    • registry.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 Down
DROP 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
Terminal window
git add db/migrations/00005_registry.sql internal/platform/registry
git 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) string
    • i18n.WithLocale(ctx, locale string) context.Context / i18n.FromContext(ctx) string
    • i18n.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 Down
DROP 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
Terminal window
git add db/migrations/00006_translation.sql internal/platform/i18n
git 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 methods
    • internal/gen/api — generated server interfaces and types
    • internal/kernel/vocab and packages/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: true

db/queries/organization.sql:

-- name: GetOrganization :one
SELECT id, org_id, name, created_at FROM organization WHERE id = $1;
-- name: ListOrganizations :many
SELECT 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.3
info:
title: mcom ERP API
version: 0.1.0
servers:
- url: /v1
paths:
/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: api
output: internal/gen/api/api.gen.go
generate:
gin-server: true
models: true
strict-server: true

Run:

Terminal window
go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest
oapi-codegen -config oapi-codegen.yaml api/openapi.yaml
go 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.manage
errorTypes:
- validation
- not-found
- conflict
- forbidden
- unauthorized
- internal

tools/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:

Terminal window
go get gopkg.in/yaml.v3
go 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.

Terminal window
git add sqlc.yaml db/queries api/openapi.yaml oapi-codegen.yaml constants.yaml \
tools/vocabgen internal/gen internal/kernel/vocab packages/contracts
git 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 middleware
    • httpx.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
Terminal window
git add internal/kernel/httpx cmd
git commit -m "feat(kernel): HTTP middleware chain, router and service entrypoints"

  • make lint && make test && make build passes from a clean checkout.
  • A new table without org_id, RLS enabled and forced, and an org_isolation policy fails the build.
  • A new os.Getenv call without a matching .env.example entry fails the build.
  • constants.yaml edited without regenerating fails the build.
  • GET /v1/health returns {"status":"ok"}; any /v1/* route without X-Org-Id returns application/problem+json with 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.