In one paragraph
Bowline is the platform a mid-size freight forwarder runs on. It carries the company hierarchy (seven levels, 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 for a freight company: the domain carried over, everything else was rebuilt around a Rust core with the heavy or specialised jobs (PDF rendering, spreadsheets, forecasting, mail delivery) in the language that suits them.
Key numbers
Rust API, Next.js web, Java billing, Python analytics, Go outbox worker and CLI, Terraform infrastructure, each with its own CI job and Docker image.
Eight versioned migrations: org and identity, HR, operations, finance, communications, audit and outbox, reference data. 52 permissions in 14 roles.
Database integrity rules proven by a rolled-back SQL test in CI: one CEO, no reporting cycles, subtree cascade, balanced ledger, immutable lines, closed periods, leave overlap, append-only audit.
A seeded company with a full reporting tree, so every permission scope, approval chain and messaging rule can be exercised from real logins.
What makes it more than a CRUD app
| Concern | How Bowline handles it |
|---|---|
| Chain of command | Each employee's reporting path is a PostgreSQL ltree maintained by triggers. Re-parenting a manager rewrites the whole subtree in one statement; cycles and a second CEO are rejected. "Everyone below me" is a GiST index lookup, and every scoped permission (:self, :subtree, :department, :all) is evaluated against the same path. |
| Money | Double-entry ledger. 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. Invoices, payments, expenses, vendor bills and payroll are all just sources of journal entries. |
| Who can message whom | A rule table, enforced in the API and reflected in the recipient picker: support desk, manager and reports, same department for everyone; the subtree for managers; the whole company for executives. |
| Approvals | Leave goes to the direct manager; expenses need manager then finance; invoices above a threshold need finance approval before issue; payroll needs the CFO; closing a period locks it. |
| Authentication | Argon2id passwords, 15-minute access tokens, rotating refresh tokens with family reuse detection, lockout after failed logins, forced password change on first login. The web app keeps tokens in httpOnly cookies behind a backend-for-frontend proxy. |
| Email delivery | Transactional outbox: every message writes rows in the same transaction; a Go worker claims them with FOR UPDATE SKIP LOCKED, retries with exponential backoff, and parks failures after eight attempts. |
| Attribution | Every mutation writes actor, request id and a before/after snapshot to an append-only audit log, queryable by executives and auditors. |
| Operations | Health and readiness probes, Prometheus metrics, structured logs with request ids, one command for local development, one pipeline to production (OIDC to AWS, ECR, Terraform apply behind a protected environment, migrations as a one-off task). |
Architecture
+------------------+
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
+------------------+
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; the notify worker may touch only the outbox table.
Verification
Every number here came from running the system, on PostgreSQL 16 with all six services up. None of it is an estimate.
Automated tests across five languages: 66 TypeScript, 62 Rust (27 unit and 35 integration over the real HTTP surface), 58 Go, 45 Java, 35 Python.
Steps of the end-to-end scenario, which drives seven seeded roles through one working day and checks the rules rather than the plumbing.
API operations across 99 OpenAPI paths, with clippy --all-targets -- -D warnings clean.
The trial balance after seeding 260 employees, 300 shipments, 90 invoices and 180 journal entries, and again after the scenario issues and collects another invoice.
The scenario is the interesting part, because each step is a rule that could plausibly be got wrong:
| Step | What it proves |
|---|---|
| A dock worker announces to the company | Refused. Only messages:broadcast:company holders may, and the CEO's announcement still reaches that worker's inbox. |
| A dock worker opens a thread with the CFO | Refused, while a thread with their own manager succeeds. The messaging rules are enforced server side, not just hidden in the recipient picker. |
| A shipment jumps straight to delivered | 409 invalid_transition, while draft to booked to picked_up is accepted. |
| Someone else moves a driver's work order | Refused. Only the assignee or a manager above them may. |
| An invoice is overpaid | 422. The correct payment posts, and the trial balance still sums to zero. |
| An unbalanced journal entry is posted | 422, surfaced cleanly rather than exploding at commit, because the deferred constraint is checked for one round trip inside the transaction. |
| An entry is posted into a closed period | Refused at the database, not merely in the API. |
| A dock worker reads the ledger or the audit trail | 403 for both, while the CEO reads the audit rows for the invoice just issued. |
Three bugs found by booting the service that no unit test would have caught: two Prometheus recorders fighting over a port, a stack overflow building a 99 route tower stack, and an OpenAPI schema that recursed forever on the self referencing org chart. One authorisation leak found by the integration suite: GET /employees/{id}/reports accepted org:read, which every user holds, so any employee could read anyone's reports along with their contact details.
Run it
git clone https://github.com/rhs2/bowline && cd bowline cp .env.example .env make up && make migrate && make seed make api # http://localhost:8080, Swagger UI at /docs make web # http://localhost:3000, sign in as ceo@bowline.example / Bowline!2026 make test # every suite; make smoke walks the end-to-end scenario