DockTally
A commercial farm ran its packing floor on a paper form. Now it runs on a multi-tenant SaaS — and the farm is tenant #1, not the owner.
- Role
- Founder & sole developer
- Timeline
- Jun 2026 – present
- Status
- In daily production use
- Type
- Multi-tenant B2B SaaS
Every schema change versioned; none edited after it was applied.
Billing, document ingest, notifications, and public endpoints.
All tenant-scoped, all row-level-security protected by default.
iOS app and web portal, one shared type layer.
The record was a piece of paper.
Sweet Valley Produce logged every production day on Form 011 — a handwritten sheet where each row was a packing run and a cell like “9 / 8” meant two packers had made nine boxes and eight.
| Run | Field / lot | Bins | Boxes | Init. |
|---|---|---|---|---|
| 1 | N-12 | 6 | 9 / 8 | M·J |
| 2 | N-12 | 4 ¾ | 11 | M |
| 3 | S-03 | 3 ½ | 7 / 5 / 6 | R·D·J |
| 4 | S-03 | 2 ⅛ | 4 | R |
It worked, until someone needed to answer a question. Which field did this pallet come from? How much did that grower actually deliver last month? Which packer’s output is dropping? Every answer meant digging through a binder, and a buyer complaint had no traceable path back to a lot.
I spent a summer embedded with the operation as an F3 Innovate intern — on the dock, with the owners and the receiving staff — before writing the schema. That is why the data model mirrors the paper rather than replacing it with something tidier.
The whole operation, end to end.
Five stages, one continuous chain of custody — a bin received in the morning is traceable through to the invoice it lands on.
Receiving
Inbound bins logged against a grower agreement — hauler, freight terms, quantity, quality, and the BOL number that ties it to the load.
Packing
One entry per row of the old paper form. Bins are a fraction picker (full, ¾, ½, ¼, ⅛) because that is how partial bins were actually recorded, and boxes are stored per packer so output-by-packer reporting is a group-by rather than a rewrite.
Storage
Lots tracked by bin with QR-coded tags, so a finished pallet resolves back to the field and the day it came from.
Orders
Public order intake, quote and negotiation, allocation against on-hand stock, dispatch notice, and proof of delivery.
Invoicing
Sequential document numbers, duplicate-invoice guards, Stripe billing, and an immutable audit log behind the whole chain.
Isolation is a database property, not a code convention.
The hard requirement in multi-tenant B2B software is that one customer can never see another’s data. Enforcing that in application code means every future query is a chance to get it wrong.
Row-level security is the boundary
Every domain row carries a farm_id, and Postgres row-level security policies — not the client — decide what a request can read or write. Reads filter to the caller’s farms; writes are additionally gated on their role. A query that forgets to scope itself returns nothing rather than someone else’s data.
New tables are locked by default
An ensure_rls event trigger enables row-level security in deny-all mode on any new table the moment it is created. A migration that adds a table without also adding policies produces a table nobody can read — the failure mode is a visibly broken feature instead of a silent data leak.
Isolation is proven, not assumed
supabase/tests/isolation_test.sqlstands up two farms and asserts that neither can read or write the other’s rows, that anonymous access is fully locked out, and that each role is held to its policy. A trimmed harness runs in CI, so a policy regression fails the build.
62 edge functions carry the side effects
Anything that touches the outside world runs server-side with a service role the client never holds: Stripe checkout, subscriptions, Connect onboarding and webhooks; document ingest, extraction and reconciliation through the Claude API; email via Resend, SMS, and push; and the public endpoints that let a buyer submit or track an order without an account.
The record is append-only where it matters
Compliance data is only worth keeping if it cannot be quietly rewritten. An immutable audit log, sequential document numbering, duplicate-invoice guards, edit markers, and a write guard on finalized production days mean corrections are recorded as corrections.
Stack
- React Native (Expo)
- TypeScript
- PostgreSQL
- Row-level security
- Supabase
- Deno edge functions
- Stripe + Stripe Connect
- Claude API
- Resend
- Expo push
- Sentry
- EAS build + OTA
- GitHub Actions
Sweet Valley Produce retired the handwritten log.
The operation runs its production days in DockTally from a phone at the dock. Yields, per-packer output, and grower settlements come out of the same records that used to live in a binder.
The architecture decision that mattered was refusing to build a single-farm app. Nothing in the schema treats Sweet Valley as special — it is simply the first tenant, which is why onboarding a second farm is a signup rather than a rewrite.



