Freight operations and workforce management for a 260-person logistics company, from the CEO to the dock crew: one chain of command, one ledger, one audit trail.
Project page: rhs2.github.io/bowline · Documentation: index · Architecture · Domain model · API contract · Runbook · Security
Bowline is the platform a mid-size freight forwarder runs on. It carries the company hierarchy (levels 1 to 7, CEO to ground staff), identity and access, HR (leave, shifts, attendance, documents), freight operations (customers, shipments, legs, tracking events, work orders, inventory), double-entry finance (invoices, payments, expenses, vendor bills, payroll, period close, reports), internal mail with company-wide announcements, a support desk with SLAs, and an append-only audit log. It is the 2026 re-engineering of an internal platform first built in 2022 to 2023; the domain carried over, everything else was rebuilt around a Rust core with the heavy or specialised jobs in the language that suits them.
Every number below came from running the thing, on PostgreSQL 16 with all six services up. Nothing here is an estimate.
| Suite | Result |
|---|---|
api (Rust) |
62 tests: 27 unit, 35 integration over the real HTTP surface against a throwaway database per binary |
web (Vitest) |
66 tests, lint and tsc --noEmit clean, production build with no backend running |
tools (Go) |
58 tests, go vet clean, stable over repeated runs |
billing (JUnit) |
45 tests, ./mvnw -B verify BUILD SUCCESS |
analytics (pytest) |
35 tests, ruff check and ruff format --check clean |
db |
11 integrity rules proven by a rolled-back SQL scenario, against both an empty and a seeded database |
infra |
12 of 12 Terraform directories validate; a real plan resolves 187 production resources |
| Total | 266 automated tests, plus a 33 step end-to-end scenario |
The API answers 99 OpenAPI paths / 126 operations, and cargo clippy --all-targets -- -D warnings is clean.
scripts/smoke.sh drives seven seeded roles through one working day and checks the
rules rather than the plumbing: a dock worker is refused when announcing to the
company but receives the CEO's announcement; they cannot open a thread with the CFO
but can message their own manager; a shipment jump straight to delivered is
refused while the legal chain succeeds; a work order moves only for its assignee;
overpayment and unbalanced entries are both rejected; issuing an invoice posts a
balanced journal entry and the trial balance still sums to zero; posting into a
closed period fails; and the audit trail is readable by the CEO and not by the dock
worker. All 33 pass.
$ make seed
260 employees, 22 departments, 300 shipments, 90 invoices, 180 journal entries
trial balance 0.00 employees without a manager 1 deepest reporting chain 6
$ ./scripts/smoke.sh
All 33 checks passed.
Roughly 22,100 lines of Rust, 14,100 of TypeScript, 4,900 of Go, 4,700 of Terraform, 3,600 of Java, 2,500 of Python and 1,150 of SQL, with 4,000 lines of documentation.
- The org chart is a data structure, not a picture. Every employee's reporting
path is a PostgreSQL
ltreemaintained by triggers: exactly one CEO, cycles are rejected, re-parenting a manager rewrites their whole subtree in one statement. "Everyone below me" is a GiST index lookup, and every permission that says:subtree,:departmentor:allis evaluated against that same path. - Money cannot go wrong quietly. The ledger is double entry; a deferred constraint trigger refuses to commit an unbalanced entry, journal lines are immutable (corrections are reversing entries), and posting into a closed fiscal period fails at the database, not just in the API.
- Who can talk to whom is a rule, not a habit. A dock worker can write to the support desk, their supervisor and their own department; a manager can reach their subtree; only executives can address the whole company. The recipient picker in the UI and the API enforce the same table.
- Every write is attributable. Mutations carry the acting user, request id and a before/after snapshot into an append-only audit log, in the same transaction.
- Nothing is lost when the mail provider is down. Emails are rows in a
transactional outbox; a Go worker claims them with
SKIP LOCKED, retries with backoff, and parks failures for inspection.
+------------------+
browser ---------> | web (Next.js) | BFF: httpOnly cookies, /api/proxy
+--------+---------+
| HTTPS /api/v1 (JWT bearer)
v
bowctl (Go) ------> +------------------+ +--------------------+
| api (Rust) |----->| billing (Java) | invoice PDFs,
| Axum + SQLx | | Spring Boot | statements, AR aging xlsx
| identity, org, | +--------------------+
| hr, ops, | +--------------------+
| finance, comms, |----->| analytics (Python) | delay risk,
| audit | | FastAPI + sklearn | volume forecast
+---+--------+-----+ +--------------------+
| |
PostgreSQL 16 S3 (documents, PDFs)
(ltree, citext)
^
| outbox table, SKIP LOCKED
+---+--------------+
| notify (Go) | SES in production, Mailpit locally
+------------------+
| Service | Language / framework | Owns |
|---|---|---|
api |
Rust 1.98, Axum 0.7, SQLx 0.8, Tokio | All business rules, authorisation, migrations, OpenAPI, seed data |
web |
TypeScript, Next.js 15, Tailwind | Role-aware UI: dashboards, org chart, inbox, support, HR, ops, finance, admin |
billing |
Java 17, Spring Boot 3, OpenPDF, POI | Invoice PDFs, customer statements, AR aging spreadsheets |
analytics |
Python 3.12, FastAPI, scikit-learn | Shipment delay-risk scoring, weekly volume forecasts |
tools |
Go 1.27 | notify outbox worker, bowctl operator CLI |
infra |
Terraform, GitHub Actions | AWS (ECS Fargate, RDS, ElastiCache, S3, SES), CI, build and deploy |
db |
SQL | Versioned migrations, reference data, integrity triggers and tests |
The API is the only writer of business data. billing and analytics read through
a read-only database role and are called by the API over HTTP; notify may touch
only the outbox table. Full detail in docs/ARCHITECTURE.md.
Seeded as Bowline Logistics: about 260 people across Executive, Operations (sea, air, road, warehousing, fleet, customs), Finance (accounting, billing, payroll, procurement), People, Technology (platform, service desk) and Commercial, on five sites. Fourteen roles bundle 52 permissions; approval chains (leave, expenses, invoices above a threshold, payroll, period close) follow the reporting line. See docs/DOMAIN.md for the role table and every workflow.
cp .env.example .env
make up # Postgres 16, Redis 7, Mailpit, MinIO
make migrate # applies db/migrations (the API also does this on start)
make seed # 260 employees, customers, shipments, invoices, a balanced ledger
make api # http://localhost:8080 (Swagger UI at /docs)
make web # http://localhost:3000Sign in as ceo@bowline.example / Bowline!2026 (or driver@, dock.worker@,
support.agent@, accountant@, cfo@, hr.admin@, all @bowline.example) to see
the platform from different levels of the hierarchy. Mail sent by the platform lands
in Mailpit at http://localhost:8025.
make test runs every suite (Rust integration tests against Postgres, Vitest, JUnit,
pytest, Go). make smoke walks an end-to-end scenario against the running stack:
the CEO announces to the company, a dock worker opens a ticket that an agent
resolves, a dispatcher books a shipment and a driver completes the work order, an
accountant issues and collects the invoice, the trial balance stays at zero, and a
post into a closed period is refused.
api/ Rust API: src/{auth,org,hr,ops,finance,comms,support,admin}, bins: bowline-api, migrate, seed
web/ Next.js app: app/(app)/{dashboard,org,people,inbox,support,hr,ops,finance,admin}, lib/api.ts
billing/ Spring Boot: render/invoice, statements, reports/ar-aging.xlsx
analytics/ FastAPI: score/delay-risk, forecast/volume, train.py
tools/ Go: cmd/notify (outbox worker), cmd/bowctl (CLI), internal/outbox
db/ migrations/0001..0008, init/roles.sql, tests/integrity.sql
infra/ terraform/{modules,environments}, deployment notes
docs/ ARCHITECTURE, DOMAIN, API, RUNBOOK, SECURITY, project page
scripts/ dev-up.sh, smoke.sh, leak_scan.sh
.github/ ci.yml (one job per service) and deploy.yml (ECR + Terraform)
internal/ working notes, excluded from the repository
- Migrations are the contract. Eight SQL files under
db/migrations; the integrity rules have their own test (db/tests/integrity.sql) that CI runs after applying the migrations twice, proving reference data is idempotent. - Tests run against real Postgres. The Rust suite creates a throwaway database per run, migrates it, and exercises login lockout, refresh-token reuse detection, scope filtering, re-parenting, state machines, ledger posting and messaging rules through the HTTP layer.
- Twelve-factor everywhere. Every variable is documented in
.env.example; production values come from AWS Secrets Manager through the ECS task definitions. - One pipeline.
ci.ymllints, tests and builds every service in parallel;deploy.ymlassumes an OIDC role, pushes images to ECR, applies Terraform behind a protected environment and runs migrations as a one-off task.
Everything in this repository is meant to be public, so the boundary is enforced rather than remembered.
.gitignore is ordered by consequence: secrets and real configuration first,
then Terraform state (which contains every resolved endpoint and generated
password), then internal/, then data, then the ordinary build output. The one
environment file that ships is .env.example, and it holds documented
placeholders.
./scripts/leak_scan.sh scans what git would publish, not the working tree,
because a secret in an ignored file is not a leak and the same secret in a tracked
file is. It looks for private keys, cloud and service tokens, JWTs, real AWS
account ids and ARNs, addresses outside the reserved test domains, credential
literals, and files that should never be tracked. The terms that identify a real
person or company are not listed in the script itself, since writing them there
would publish exactly what the check exists to catch; they load from
scripts/private_patterns.txt, which is excluded.
It runs three ways: by hand, from .githooks/pre-commit (enable once with
git config core.hooksPath .githooks), and in CI, where it is joined by a
full-history scan so that something committed and later deleted is still caught.
The scanner is tested the way any other guard should be: planting each kind of secret and confirming it fails, then removing them and confirming it passes.
MIT. The company, people, customers and figures in the seed data are fictional,
generated by make seed from a fixed random seed.