Human-governed document operations for supplier invoice processing.
Trestle turns invoices into reviewable, auditable cases. AI extracts structured data and Field Evidence; deterministic rules validate financial facts; authorized people retain control over correction, confirmation, approval, rejection, and posting.
Important
Trestle is an operational MVP and demo system. Use synthetic fixtures for public demonstrations. Production documents require private mode, configured storage, managed PostgreSQL, and reviewed deployment secrets.
Trestle supports three pre-created demonstration roles:
| Role | Responsibilities |
|---|---|
| AP Specialist | Submit a Source Document, review and correct the Invoice Draft, resolve findings, confirm the current revision, and initiate posting |
| Finance Approver | Review policy exceptions, approve, return for correction, or reject the current case revision |
| Administrator | Maintain limited Approval Policy, inspect reference data and operations, and reset synthetic demo data |
Core workflow:
- An AP Specialist submits one PDF, JPEG, or PNG Source Document.
- The API stores the document, creates an Invoice Case, and queues processing.
- A worker prepares the document, extracts fields and evidence, then applies deterministic validation.
- The AP Specialist compares document and draft, corrects fields, resolves findings, and confirms the current version.
- Clean cases become Ready to Post. Cases with Approval Required findings enter the shared Finance queue.
- A Finance Approver approves, returns, or rejects the current revision.
- An AP Specialist explicitly posts an eligible case to the simulated Accounting System.
- The Case Timeline retains attempts, corrections, findings, decisions, retries, actors, timestamps, and versions.
Received -> Processing -> Awaiting AP Review
-> Extraction Failed -> Processing | Rejected
Awaiting AP Review -> Ready to Post
-> Awaiting Finance Approval
-> Processing | Rejected
Awaiting Finance Approval -> Ready to Post
-> Returned for Correction
-> Rejected
Returned for Correction -> Awaiting Finance Approval
-> Ready to Post
-> Processing | Rejected
Ready to Post -> Posting -> Posted
-> Posting Failed -> Posting | Returned for Correction | Rejected
Posted and Rejected are terminal and immutable. Validation Findings explain whether transitions are allowed; they are not lifecycle states.
- PostgreSQL is authoritative for financial state, permissions, corrections, decisions, and posting records.
- AI extracts values, confidence categories, and evidence. It never validates financial rules, selects authoritative records, authorizes, approves, rejects, or posts.
- Every financial command checks actor authority, lifecycle state, and expected Invoice Case version.
- Human corrections remain authoritative. Reprocessing proposes a revision instead of overwriting them.
- Retryable side effects use stable idempotency keys and tolerate at-least-once delivery.
- Source Documents, invoice values, raw prompts, signed URLs, credentials, and session tokens stay out of ordinary logs and traces.
- Public demonstrations use synthetic documents and bounded model usage.
Included:
- One seeded Organization with AP Specialist, Finance Approver, and Administrator accounts
- Single-document intake for content-verified PDF, JPEG, and PNG files up to 10 MB and 5 pages
- Multimodal extraction with strict structured output and Field Evidence
- Deterministic arithmetic, duplicate, Vendor, Purchase Order, and two-way matching rules
- AP Confirmation, conditional Finance Approval, correction, rejection, and explicit posting
- Durable workflow suspension, background jobs, retries, idempotency, and optimistic concurrency
- Local filesystem and private Cloudflare R2 document-storage adapters
- Simulated Accounting System with deterministic success and failure modes
- Versioned synthetic evaluation reports for quality, evidence, latency, usage, and estimated cost
Excluded:
- Multiple Organizations, registration, invitations, subscriptions, and billing
- Email ingestion, batch intake, external notifications, and native mobile apps
- Real ERP integration, payments, bank-detail processing, or currency conversion
- Vendor or Purchase Order creation, goods receipts, or three-way matching
- Generic workflow and policy builders, advanced analytics, or autonomous financial actions
- Confidential production documents or unrestricted public uploads
| Area | Current repository | Accepted MVP target |
|---|---|---|
| Web | React SPA with Work Queue, review workbench, Finance decisions, recovery actions, and Administration UI | Implemented |
| API | Hono/OpenAPI routes for auth, intake, cases, commands, documents, Finance, administration, and health | Implemented |
| Persistence | PostgreSQL/Drizzle aggregate, references, attempts, evidence, findings, decisions, events, quotas, and balances | Implemented |
| Background work | Graphile Worker with separate API/worker processes, bounded attempts, replay-safe handlers, and cron maintenance | Implemented |
| Workflow | Mastra PostgreSQL snapshots coordinated against authoritative PostgreSQL state | Implemented |
| AI | OpenRouter vision adapter with strict validation, timeout, fallback, and sanitized usage metadata | Implemented; opt-in by environment |
| Documents | Private local filesystem or S3-compatible R2 adapter with authorized access and 10 MB/5-page limits | Implemented |
| Accounting | Idempotent simulated Accounting System with temporary and permanent failure modes | Implemented |
| Auth | Seeded accounts, opaque sessions, role permissions, origin checks, secure cookies, and bounded login throttling | Implemented |
| Tests | Domain, PostgreSQL, workflow, authorization, worker, frontend, evaluation, and redaction coverage | Implemented |
| Deployment | Development and production-shaped Compose stacks with separate API/worker services and persistent documents | Implemented; managed DB/R2 wiring remains deployment-specific |
Warning
Do not put production credentials or confidential documents in the public demo. Public mode accepts only server-known synthetic fixtures and applies account/IP quotas.
Current runtime:
Browser SPA -> Hono API -> Repository port -> Drizzle -> PostgreSQL
Accepted target keeps one modular server codebase with separate API and worker processes:
flowchart LR
Web[React web app] --> API[Hono API]
API --> DB[(PostgreSQL)]
API --> Documents[Private document store]
Worker[Node worker] --> DB
Worker --> Documents
Worker --> Model[Extraction provider]
Worker --> Accounting[Simulated Accounting System]
API and worker may scale independently, but both call the same application operations and adapters. PostgreSQL owns business state; workflow snapshots own recoverable execution checkpoints.
| Layer | Technology |
|---|---|
| Web | React 19, TypeScript 5.9, Vite 8 |
| Routing and server state | TanStack Router, TanStack Query |
| Browser transport and state | Ky, Zustand, Immer |
| UI | Tailwind CSS 4, shadcn, Base UI, Phosphor Icons |
| Localization | i18next with English and Indonesian resources |
| API | Hono 4, Zod, @hono/zod-openapi, Scalar |
| Data | PostgreSQL 16, Drizzle ORM, node-postgres |
| Logging and middleware | Pino, CORS, secure headers, request IDs, rate limiting |
| Tests | Vitest, Testing Library, MSW, Hono testClient |
| Tooling | pnpm workspaces, oxlint, oxfmt, Husky, lint-staged |
Document preparation uses pdfjs-dist, @napi-rs/canvas, and sharp; R2 uses the AWS S3 client; workflows use Mastra PostgreSQL and jobs use Graphile Worker.
trestle/
├── apps/
│ ├── server/ # Hono API, Drizzle, repositories, tests
│ └── web/ # React SPA, routes, features, UI, tests
├── config/
│ └── typescript/ # Shared TypeScript configuration
├── packages/ # Reserved workspace area; currently empty
├── docker-compose.yml # Development stack
├── docker-compose.prod.yml # Production-shaped template
└── pnpm-workspace.yaml
- Node.js 22 or newer
- pnpm 10
- Docker with Docker Compose for the recommended setup
cp .env.example .env
pnpm devDocker Compose starts PostgreSQL, API, worker, and web services. API startup applies Drizzle, Graphile Worker, and controlled Mastra bootstrap; the worker processes queued jobs separately. Documents persist in the document_data volume. It serves:
- Web: http://localhost:3001
- API: http://localhost:3000
- OpenAPI schema: http://localhost:3000/doc
- API reference: http://localhost:3000/reference
Seed demo accounts with a server-side password before using the application:
SEED_USER_PASSWORD='local-only-password' pnpm --filter server db:seedStop the stack without deleting database data:
docker compose downDelete the local PostgreSQL volume only when discarded data is acceptable:
docker compose down -vStart PostgreSQL, then configure each application:
cp apps/server/.env.example apps/server/.env
cp apps/web/.env.example apps/web/.env
pnpm install
pnpm dev:localdev:local starts web and server processes in parallel. Server predev applies Drizzle, Graphile, and Mastra bootstrap steps.
Root .env configures Docker Compose PostgreSQL values:
| Variable | Required | Purpose |
|---|---|---|
POSTGRES_USER |
Yes | Development PostgreSQL user |
POSTGRES_PASSWORD |
Yes | Development PostgreSQL password |
POSTGRES_DB |
Yes | Development database name |
CORS_ORIGINS |
Production Compose | Allowed web origin |
apps/server/.env configures a direct server process:
| Variable | Default | Purpose |
|---|---|---|
NODE_ENV |
production |
development, production, or test |
HOST |
localhost |
Bind hostname |
PORT |
3000 |
API port |
DATABASE_HOST |
localhost |
PostgreSQL host |
DATABASE_PORT |
5432 |
PostgreSQL port |
DATABASE_USER |
Required | PostgreSQL user |
DATABASE_PASSWORD |
Required | PostgreSQL password |
DATABASE_DB |
postgres |
Database name |
DATABASE_POOL_MAX |
10 |
Maximum pool connections |
CORS_ORIGINS |
http://localhost:3001 |
Comma-separated allowed origins |
RATE_LIMIT_WINDOW_MS |
60000 |
Rate-limit window in milliseconds |
RATE_LIMIT_MAX |
50 |
Requests allowed per window |
BODY_SIZE_LIMIT |
102400 |
Request body limit in bytes |
DOCUMENT_STORAGE_ROOT |
./var/documents |
Private local document storage |
DEMO_MODE |
private |
public restricts intake to fixtures |
DEMO_EXTRACTION_QUOTA_PER_ACCOUNT |
20 |
Account quota per period |
DEMO_EXTRACTION_QUOTA_PER_IP |
5 |
HMAC-hashed IP quota per period |
DEMO_QUOTA_PERIOD_HOURS |
24 |
Quota window |
OPENROUTER_API_KEY |
Empty | Enables real extraction when configured |
R2_* |
Empty | Enables private S3-compatible storage |
apps/web/.env contains one public value:
| Variable | Purpose |
|---|---|
VITE_API_URL |
Hono API base URL, such as http://localhost:3000 |
Only VITE_ variables may enter the browser bundle. Never place secrets in web environment variables.
# Development
pnpm dev # Docker Compose stack
pnpm dev:local # Direct web and server processes
pnpm dev:web
pnpm dev:server
# Verification
pnpm lint
pnpm typecheck
pnpm test -- --run
pnpm build
# Focused tests
pnpm test:web -- --run
pnpm test:server -- --run
# Database
pnpm --filter server db:generate # Generate migration from schema changes
pnpm db:migrate # Apply committed development migrations
pnpm db:migrate:test # Apply migrations to test database
pnpm db:push # Disposable local exploration only
pnpm db:studio
# UI primitives
pnpm --filter web shadcn:add <component>Review generated migration SQL before applying or committing it. Do not rewrite migrations that another environment may have applied.
- Web tests use jsdom, Testing Library,
userEvent, and MSW at the network boundary. renderPage()creates an isolated memory router and Query Client for route behavior.- Server route tests use Hono
testClientwith fresh in-memory repository adapters. - Server setup still requires a disposable PostgreSQL test database for migrations and transaction infrastructure.
- In-memory adapters do not prove PostgreSQL constraints, locking, transactionality, or SQL behavior.
- Playwright and complete Trestle end-to-end scenarios are planned but not installed.
The repository currently has no tracked apps/server/.env.test.example. Create apps/server/.env.test from the server environment shape and point it at a disposable test database before running server tests. Do not reuse production data.
Full quality gate:
pnpm lint
pnpm typecheck
pnpm test -- --run
pnpm buildImportant routes:
GET /healthPOST /auth/login,POST /auth/logout,GET /auth/sessionPOST /invoice-cases/intakeGET /invoice-cases,GET /invoice-cases/{id}, timeline and private document access- AP commands: confirm, correct, resolve, reprocess, reject, post
- Finance command:
POST /invoice-cases/{id}/finance-decision - Administration: policy, Vendors, Purchase Orders, usage summary, and demo reset
GET /health,GET /doc,GET /reference
All public routes validate through Zod/OpenAPI schemas. Commands require authenticated actors, explicit role permissions, lifecycle checks, and expected versions. Browser transport uses Ky with normalized error envelopes and TanStack Query invalidation.
The offline evaluation harness lives under apps/server/src/evals/. It scores versioned synthetic manifests without network calls:
pnpm --filter server test -- --run scorerThe opt-in live command requires OPENROUTER_API_KEY and reports harness, schema, prompt, model, token, and score metadata:
pnpm --filter server exec tsx scripts/eval-live.tsLive extraction is never part of the CI quality gate.
Trestle uses a light, restrained casework interface designed for long document-review sessions. Source Documents, financial values, findings, and human decisions outrank decoration. The target workbench places the document beside the editable draft on desktop and switches between Document and Draft panels on small screens.
UI work follows semantic OKLCH tokens, persistent labels, keyboard access, explicit status text, 44 pixel touch targets, reduced-motion support, and no color-only meaning.
Key implementation rules:
- Keep routes thin and delegate through explicit ports.
- Keep browser server state in TanStack Query and shareable navigation state in TanStack Router.
- Validate external input, environment values, uploads, model output, and integration responses.
- Store money as integer minor units with ISO currency.
- Mock at network or port boundaries and use PostgreSQL integration tests where database semantics matter.
- Update context or ADRs when implementation changes durable boundaries or invariants.
- Processing failures become
extraction_failed; authorized AP users can retry through the existing reprocess command. - Temporary posting failures retry with the same idempotency key. Permanent Accounting rejection is inspectable and cannot be blindly retried.
- Workflow snapshots are resumable; missing or invalid snapshots become inspectable processing failures.
- Background jobs have bounded attempts. Duplicate processing and posting jobs are replay-safe.
- Structured logs carry correlation, case, workflow, job, and attempt identifiers while redacting document content, invoice values, prompts, credentials, signed URLs, and provider output.
docker-compose.prod.yml builds one server image into separate API and worker services, runs health-gated startup, and persists local documents. Hosted deployments should replace local PostgreSQL with managed PostgreSQL and configure private R2 through R2_* variables.
The web image requires VITE_API_URL at build time because Vite embeds it in the static bundle:
docker build \
-f apps/server/Dockerfile.prod \
-t trestle-server:latest \
.
docker build \
-f apps/web/Dockerfile.prod \
--build-arg VITE_API_URL=https://api.example.com \
-t trestle-web:latest \
.Review CORS origins, production cookie settings, database backups, R2 least-privilege credentials, and the public/private demo mode before deployment. Never expose OPENROUTER_API_KEY, database credentials, R2 secrets, or session material to the web bundle.