);
From 26c5ffde143477deb19a6b2884decfc5e49bb706 Mon Sep 17 00:00:00 2001
From: Muhammad Fadhil
Date: Sun, 2 Aug 2026 15:20:48 +0700
Subject: [PATCH 3/5] docs: add complete hosted demo runbook
---
DEMO_GUIDE.md | 264 ++++++++
README.md | 568 +++++++++---------
examples/invoices/README.md | 22 +
examples/invoices/acme-clean-invoice-002.pdf | Bin 0 -> 2052 bytes
.../acme-currency-mismatch-invoice-004.pdf | Bin 0 -> 2051 bytes
examples/invoices/acme-duplicate-invoice.pdf | Bin 0 -> 2163 bytes
.../invoices/acme-missing-po-invoice-003.pdf | Bin 0 -> 1991 bytes
examples/invoices/clean-acme-invoice.pdf | Bin 0 -> 1663 bytes
8 files changed, 569 insertions(+), 285 deletions(-)
create mode 100644 DEMO_GUIDE.md
create mode 100644 examples/invoices/README.md
create mode 100644 examples/invoices/acme-clean-invoice-002.pdf
create mode 100644 examples/invoices/acme-currency-mismatch-invoice-004.pdf
create mode 100644 examples/invoices/acme-duplicate-invoice.pdf
create mode 100644 examples/invoices/acme-missing-po-invoice-003.pdf
create mode 100644 examples/invoices/clean-acme-invoice.pdf
diff --git a/DEMO_GUIDE.md b/DEMO_GUIDE.md
new file mode 100644
index 0000000..aa05ba1
--- /dev/null
+++ b/DEMO_GUIDE.md
@@ -0,0 +1,264 @@
+# Trestle demo guide
+
+This guide is the click-by-click walkthrough for a hosted or local Trestle
+demonstration. Use only the synthetic PDFs in `examples/invoices/`.
+
+## Before you start
+
+You need:
+
+- A running web app and API at `http://localhost:3001` and `http://localhost:3000`.
+- A running worker. The API accepts uploads, but the worker moves cases beyond
+ `Received`.
+- Three seeded accounts with the same password. Create or update them with:
+
+ ```bash
+ SEED_USER_PASSWORD='<12+ character password>' pnpm --filter server db:seed
+ ```
+
+- An administrator reset enabled. For local private mode, set
+ `DEMO_RESET_ENABLED=true` and restart the API. Public mode enables reset by
+ default.
+
+Open the app at .
+
+## Demo accounts
+
+| Role | Email | Use it for |
+| ---------------- | ------------------------------- | ------------------------------------------------------------ |
+| AP Specialist | `ap.specialist@trestle.demo` | Upload, review, correct, confirm, reject, and post |
+| Finance Approver | `finance.approver@trestle.demo` | Approve, return, or reject policy exceptions |
+| Administrator | `administrator@trestle.demo` | Configure policy, inspect reference data, and reset the demo |
+
+The password is intentionally not stored in the repository.
+
+## Seeded reference data
+
+A reset restores this baseline:
+
+- Vendor: `Acme Supplies`
+- Purchase Order: `PO-1001`
+- Currency: `USD`
+- Remaining Purchase Order amount: `USD 1,000.00`
+- One Purchase Order line: `Paper`, quantity `100`
+- High-value approval threshold: `USD 1,000.00`
+- Purchase Order tolerance: `2%`
+- Missing Purchase Order requires Finance approval: enabled
+
+Reset between scenarios unless a scenario explicitly says to build on the
+previous case. Reset removes cases, documents, findings, decisions, posting
+attempts, and quotas; it restores the reference data above.
+
+## Scenario map
+
+| Scenario | File | Main path | Expected outcome |
+| ---------------------- | ---------------------------------------- | ---------------------------------------------------------- | --------------------------------------- |
+| Clean AP posting | `clean-acme-invoice.pdf` | AP review → confirm → post | `Posted` |
+| High-value approval | `acme-clean-invoice-002.pdf` | AP review → Finance approval → post | `Posted` |
+| Finance return | `acme-clean-invoice-002.pdf` | AP → Finance return → correction → Finance approval → post | `Posted` |
+| Missing Purchase Order | `acme-missing-po-invoice-003.pdf` | AP review → Finance approval, or warning-only variant | `Posted` or `Awaiting Finance Approval` |
+| Currency mismatch | `acme-currency-mismatch-invoice-004.pdf` | AP review → Blocking Finding | Reject or correct |
+| Business duplicate | `acme-duplicate-invoice.pdf` | AP review → duplicate finding | Reject or correct |
+| Exact duplicate upload | Any identical file twice | Intake duplicate check | Existing case link; no new case |
+
+The PDFs and their intended checks are also documented in
+[`examples/invoices/README.md`](examples/invoices/README.md).
+
+## Scenario 1: clean AP-only posting
+
+This is the shortest complete workflow.
+
+1. Reset the demo as Administrator.
+2. Sign in as AP Specialist.
+3. Open **Cases → New case**.
+4. Upload `examples/invoices/clean-acme-invoice.pdf` and submit it.
+5. Wait for the case to move through `Received` and `Processing` to
+ `Awaiting AP Review`. Keep the worker running.
+6. Review the Source Document, extracted Invoice Draft, evidence, and findings.
+7. Resolve any resolvable findings and acknowledge warnings if the page asks
+ for acknowledgement. Blocking Findings must be corrected or resolved before
+ confirmation.
+8. Click **Confirm draft**. The case should become `Ready to Post`.
+9. Click **Post invoice**.
+10. Wait for the worker. The case should become `Posted` and show an Accounting
+ System reference in the timeline.
+
+What this demonstrates: asynchronous processing, evidence-based AP review,
+server-confirmed confirmation, explicit posting, and an idempotent accounting
+result.
+
+## Scenario 2: high-value Finance approval
+
+The second clean sample is above the default threshold only after lowering the
+policy for the demonstration.
+
+1. Reset the demo.
+2. Sign in as Administrator and open **Administration**.
+3. Set the high-value threshold to `10000` minor units, which is `USD 100.00`.
+ Leave the other policy settings unchanged and save it.
+4. Sign in as AP Specialist.
+5. Upload `examples/invoices/acme-clean-invoice-002.pdf`.
+6. Wait for `Awaiting AP Review`. Review the evidence and resolve or acknowledge
+ any findings that the page allows you to handle.
+7. Click **Confirm draft**. The case should become
+ `Awaiting Finance Approval`.
+8. Sign in as Finance Approver and open the Finance queue.
+9. Open the case and inspect the Source Document, Invoice Draft, findings, and
+ AP confirmation.
+10. Click **Approve**, then confirm the decision.
+11. Sign in as AP Specialist, open the case, and click **Post invoice**.
+12. Wait for `Posted` and inspect the Accounting System reference.
+
+What this demonstrates: an Approval Required finding routes only after AP
+Confirmation, and Finance approval is required before posting.
+
+## Scenario 3: Finance returns a case for correction
+
+Use the same low threshold as Scenario 2.
+
+1. Reset the demo.
+2. As Administrator, set the high-value threshold to `10000` minor units and
+ save the policy.
+3. As AP Specialist, upload `acme-clean-invoice-002.pdf` and wait for
+ `Awaiting AP Review`.
+4. Confirm the draft. The case should enter `Awaiting Finance Approval`.
+5. As Finance Approver, open the case and choose **Return for correction**.
+6. Enter an explanation, such as `Please verify the line description against
+the source document.`, and confirm the return.
+7. As AP Specialist, reopen the case. It should be `Returned for Correction`.
+8. Open the correction form, make a deliberate correction, and save it. For a
+ safe demonstration, change the line description to the wording visible in
+ the PDF while leaving the amount, currency, and Purchase Order unchanged.
+9. Confirm the new revision. The case should return to
+ `Awaiting Finance Approval`.
+10. As Finance Approver, approve the new revision.
+11. As AP Specialist, post the case and verify `Posted`.
+
+What this demonstrates: a Finance decision is tied to a revision, a correction
+invalidates the old approval, and the revised case requires a new decision.
+
+## Scenario 4: missing Purchase Order policy
+
+The default policy routes a missing Purchase Order to Finance.
+
+### Approval-required variant
+
+1. Reset the demo and leave **Missing Purchase Order requires approval** enabled.
+2. As AP Specialist, upload `acme-missing-po-invoice-003.pdf`.
+3. Wait for `Awaiting AP Review` and inspect the `purchase_order_missing`
+ finding.
+4. Confirm the draft. The case should become `Awaiting Finance Approval`.
+5. As Finance Approver, inspect and approve the case.
+6. As AP Specialist, post it and inspect the timeline.
+
+### Finance rejection variant
+
+Follow steps 1–9 above, then choose **Reject** as Finance Approver, enter a
+reason, and confirm the rejection. The case should become `Rejected` and the
+reason should remain in the Case Timeline.
+
+### Warning-only variant
+
+1. Reset the demo.
+2. As Administrator, disable **Missing Purchase Order requires approval** and
+ save the policy.
+3. Upload the same file as AP Specialist.
+4. Acknowledge the warning and confirm the draft. The case should become
+ `Ready to Post` instead of entering the Finance queue.
+5. Post it as AP Specialist.
+
+What this demonstrates: the same deterministic finding can route to Finance or
+remain a warning depending on the server-side Approval Policy.
+
+## Scenario 5: currency mismatch and rejection
+
+1. Reset the demo.
+2. As AP Specialist, upload `acme-currency-mismatch-invoice-004.pdf`.
+3. Wait for `Awaiting AP Review`.
+4. Inspect the `purchase_order_currency_mismatch` Blocking Finding. The PDF
+ uses EUR while `PO-1001` is USD.
+5. Because the finding blocks confirmation, choose **Reject case**.
+6. Enter a reason and confirm the rejection.
+7. Verify that the case becomes `Rejected` and that the reason appears in the
+ Case Timeline.
+
+This is the recommended path for the supplied fixture. A correction path is
+also possible if you deliberately change the draft to match authoritative
+source and reference data, then revalidate before confirming.
+
+## Scenario 6: duplicate handling
+
+This scenario covers both duplicate protections.
+
+### Business duplicate
+
+1. Reset the demo.
+2. Upload `clean-acme-invoice.pdf` as AP Specialist and let it finish
+ processing.
+3. Upload `acme-duplicate-invoice.pdf`. It has different PDF bytes but uses
+ the same supplier and invoice number as the clean sample.
+4. Open the second case after processing. It should contain a non-overridable
+ `business_duplicate` Blocking Finding.
+5. Reject the duplicate with a reason.
+
+### Exact content duplicate
+
+1. Upload the exact same `acme-duplicate-invoice.pdf` one more time.
+2. Intake should stop before creating another case and provide a link to the
+ existing case.
+3. Open the existing case from that link. Its Source Document preview should
+ remain available.
+
+Do not use the “different bytes” business-duplicate file as proof of exact
+content duplication: upload the same file bytes twice for that check.
+
+## Recovery actions
+
+### Extraction failure
+
+If a case reaches `Extraction Failed`:
+
+1. Check that the worker is running.
+2. Read the failure message and timeline entry.
+3. Fix the provider or document configuration if needed.
+4. Click **Retry extraction**. Do not upload the same document again.
+
+### Posting failure
+
+`Posting Failed` is retryable only for temporary Accounting System failures.
+Click **Retry posting** after the worker is available. Repeated attempts use the
+same idempotency key and should produce one Accounting System reference.
+
+The standard web demo uses the deterministic accounting adapter with normal
+success behavior. Temporary and permanent accounting failure modes are covered
+by automated integration tests; they are not currently selectable from the
+Administrator UI.
+
+### Stale version conflict
+
+If another user changes a case while it is open, a command can return a version
+conflict. Reload the case, review the current timeline and findings, then repeat
+the permitted action against the latest revision.
+
+## Hosted-demo operator checklist
+
+Before sharing the URL:
+
+- Use a separate database and private document bucket for the demonstration.
+- Use synthetic PDFs only.
+- Keep provider keys in the API/worker environment, never the web build.
+- Use a direct, unpooled Neon connection string. Mastra bootstrap relies on
+ session-level PostgreSQL advisory locks and can hang through a `-pooler` URL.
+- Set the exact production web origin in `CORS_ORIGINS`.
+- Use HTTPS so secure session cookies work correctly.
+- Set extraction quotas and review the model provider spending limit.
+- Set `DEMO_MODE=public` only after the public intake path is ready.
+- Confirm the Administrator reset works before sharing the URL.
+
+## Public-mode note
+
+The API's public mode accepts server-known fixture IDs and rejects arbitrary
+uploads. The current web intake surface is file-upload based, so use
+`DEMO_MODE=private` for the hosted browser walkthrough unless the deployment
+includes a fixture-picker client. Do not advertise public mode as a browser
+upload flow without that client integration.
diff --git a/README.md b/README.md
index 75e0de5..d356b3b 100644
--- a/README.md
+++ b/README.md
@@ -1,391 +1,389 @@
# Trestle
-Human-governed document operations for supplier invoice processing.
+Trestle is a human-governed document operations application for supplier
+invoice processing. AI extracts an Invoice Draft and Field Evidence;
+deterministic rules validate financial facts; authorized people control
+correction, confirmation, Finance approval, rejection, and posting.
-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.
+> Trestle is a synthetic-data MVP and portfolio demonstration. Do not upload
+> confidential or production documents.
-> [!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.
+## Start here
-## What Trestle Does
+Choose the document that matches what you need:
-Trestle supports three pre-created demonstration roles:
+| Need | Guide |
+| --------------------------------------- | ------------------------------------------------------------ |
+| Run the application locally | [Quick start](#quick-start) |
+| Walk through every user-facing scenario | [`DEMO_GUIDE.md`](DEMO_GUIDE.md) |
+| Pick an invoice for a test | [`examples/invoices/README.md`](examples/invoices/README.md) |
+| Understand the product and boundaries | [Product](#product) |
+| Configure Neon, R2, or Gemini | [Managed-service setup](#managed-service-setup) |
+| Run tests and builds | [Development](#development) |
+| Prepare a hosted synthetic demo | [Deployment checklist](#deployment-checklist) |
-| 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 |
+## Product
-Core workflow:
+### Roles
-1. An AP Specialist submits one PDF, JPEG, or PNG Source Document.
-2. The API stores the document, creates an Invoice Case, and queues processing.
-3. A worker prepares the document, extracts fields and evidence, then applies deterministic validation.
-4. The AP Specialist compares document and draft, corrects fields, resolves findings, and confirms the current version.
-5. Clean cases become Ready to Post. Cases with Approval Required findings enter the shared Finance queue.
-6. A Finance Approver approves, returns, or rejects the current revision.
-7. An AP Specialist explicitly posts an eligible case to the simulated Accounting System.
-8. The Case Timeline retains attempts, corrections, findings, decisions, retries, actors, timestamps, and versions.
+| Role | Responsibilities |
+| ---------------- | ----------------------------------------------------------------------------- |
+| AP Specialist | Upload, review, correct, resolve findings, confirm, reject, and post invoices |
+| Finance Approver | Approve, return, or reject cases requiring Finance review |
+| Administrator | Configure Approval Policy, inspect reference data, and reset synthetic data |
-### Lifecycle
+Pre-created accounts:
```text
-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
+ap.specialist@trestle.demo
+finance.approver@trestle.demo
+administrator@trestle.demo
```
-`Posted` and `Rejected` are terminal and immutable. Validation Findings explain whether transitions are allowed; they are not lifecycle states.
-
-## Product Principles
-
-- 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.
-
-## MVP Scope
-
-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
+All three use the password supplied through `SEED_USER_PASSWORD`. The password
+is never committed.
-## Implementation Status
-
-| 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.
-
-## Architecture
-
-Current runtime:
+### Workflow
```text
-Browser SPA -> Hono API -> Repository port -> Drizzle -> PostgreSQL
-```
+Received → Processing → Awaiting AP Review
+ → Extraction Failed → Processing | Rejected
-Accepted target keeps one modular server codebase with separate API and worker processes:
-
-```mermaid
-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]
-```
+Awaiting AP Review → Ready to Post
+ → Awaiting Finance Approval
+ → Returned for Correction | Rejected
-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.
+Awaiting Finance Approval → Ready to Post
+ → Returned for Correction | Rejected
-## Technology
-
-### Current Stack
+Ready to Post → Posting → Posted
+ → Posting Failed → Posting | Returned for Correction | Rejected
+```
-| 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 |
+`Posted` and `Rejected` are terminal states. Every case requires AP
+Confirmation. Finance approval is required only when current findings and the
+Approval Policy require it.
-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.
+### Product boundaries
-## Repository Layout
+- PostgreSQL is authoritative for financial state, permissions, corrections,
+ decisions, reference data, and posting records.
+- AI extracts values and evidence. It does not authorize, match authoritative
+ records, validate financial rules, approve, reject, or post.
+- Human corrections create revisions and invalidate decisions tied to older
+ revisions.
+- Source Documents are private and stored outside PostgreSQL. Document hashes
+ are used for exact duplicate detection; object keys are opaque per-upload
+ identifiers.
+- Public demonstrations must use synthetic documents and bounded extraction
+ quotas.
-```text
-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
-```
+## Quick start
-## Prerequisites
+### Prerequisites
- Node.js 22 or newer
- pnpm 10
-- Docker with Docker Compose for the recommended setup
+- Docker with Docker Compose for the recommended local stack
-## Getting Started
+### Option A: Docker Compose
-### Docker
+This is the simplest local demonstration. It uses local PostgreSQL and local
+private document storage.
```bash
cp .env.example .env
+pnpm install
pnpm dev
```
-Docker 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:
+Open:
- Web:
-- API:
+- API health:
- OpenAPI schema:
- API reference:
-Seed demo accounts with a server-side password before using the application:
+In a second terminal, seed the accounts and reference data inside the server
+container:
```bash
-SEED_USER_PASSWORD='local-only-password' pnpm --filter server db:seed
+docker compose exec -e SEED_USER_PASSWORD='local-only-password' server pnpm --filter server db:seed
```
-Stop the stack without deleting database data:
+The server startup applies Drizzle, Graphile Worker, and Mastra bootstrap
+steps. The Compose stack includes the worker. Then follow
+[`DEMO_GUIDE.md`](DEMO_GUIDE.md).
+
+Stop without deleting data:
```bash
docker compose down
```
-Delete the local PostgreSQL volume only when discarded data is acceptable:
+Delete the local database volume only when its data is disposable:
```bash
docker compose down -v
```
-### Local Processes
+### Option B: Direct local processes
-Start PostgreSQL, then configure each application:
+Use this when PostgreSQL already runs outside Compose.
```bash
cp apps/server/.env.example apps/server/.env
cp apps/web/.env.example apps/web/.env
pnpm install
-pnpm dev:local
```
-`dev:local` starts web and server processes in parallel. Server `predev` applies Drizzle, Graphile, and Mastra bootstrap steps.
-
-## Environment
-
-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.
-
-## Commands
+Configure the server database, then run the setup steps explicitly:
```bash
-# Development
-pnpm dev # Docker Compose stack
-pnpm dev:local # Direct web and server processes
-pnpm dev:web
-pnpm dev:server
+pnpm --filter server db:migrate
+pnpm --filter server queue:migrate
+pnpm --filter server mastra:bootstrap
+SEED_USER_PASSWORD='local-only-password' pnpm --filter server db:seed
+```
-# Verification
-pnpm lint
-pnpm typecheck
-pnpm test -- --run
-pnpm build
+Start the web/API processes and worker in separate terminals:
-# Focused tests
-pnpm test:web -- --run
-pnpm test:server -- --run
+```bash
+pnpm dev:local
+pnpm --filter server worker
+```
-# End-to-end (requires running Docker stack)
-E2E=1 pnpm test:e2e
+`pnpm dev:local` starts the web and API. It does not start the worker.
-# 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
+## Managed-service setup
-# UI primitives
-pnpm --filter web shadcn:add
-```
+Use this path to run the web, API, and worker locally while Neon stores
+PostgreSQL data, Cloudflare R2 stores private documents, and Google AI Studio
+performs extraction.
-Review generated migration SQL before applying or committing it. Do not rewrite migrations that another environment may have applied.
+### 1. Create the services
-## Testing
+Prepare:
-- 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 `testClient` with 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 end-to-end scenarios run opt-in with `E2E=1` against a running Docker stack.
+- A Neon database and its **direct/unpooled** connection string.
+- A private R2 bucket and an API token limited to that bucket.
+- A Google AI Studio API key with an appropriate spending limit.
-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.
+Do not use a Neon hostname containing `-pooler` for Trestle's
+`DATABASE_URL`. Mastra bootstrap uses session-level PostgreSQL advisory locks;
+transaction pooling does not preserve the required session.
-Full quality gate:
+### 2. Configure the server
```bash
-pnpm lint
-pnpm typecheck
-pnpm test -- --run
-pnpm build
+cp apps/server/.env.example apps/server/.env
```
-## API
+Set these server-only values:
+
+```dotenv
+DATABASE_URL=
+R2_ACCOUNT_ID=
+R2_ACCESS_KEY_ID=
+R2_SECRET_ACCESS_KEY=
+R2_BUCKET_NAME=
+GOOGLE_AI_STUDIO_API_KEY=
+CORS_ORIGINS=http://localhost:3001
+DEMO_MODE=private
+DEMO_RESET_ENABLED=true
+```
-Important routes:
+The R2 endpoint is derived from `R2_ACCOUNT_ID`; no R2 secret belongs in
+`apps/web/.env`.
-- `GET /health`
-- `POST /auth/login`, `POST /auth/logout`, `GET /auth/session`
-- `POST /invoice-cases/intake`
-- `GET /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`
+### 3. Bootstrap Neon and seed the demo
-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.
+```bash
+pnpm --filter server db:migrate
+pnpm --filter server queue:migrate
+pnpm --filter server mastra:bootstrap
+SEED_USER_PASSWORD='local-only-password' pnpm --filter server db:seed
+```
-## Evaluation
+If Mastra bootstrap hangs, verify that `DATABASE_URL` is direct/unpooled and
+inspect Neon for a stale advisory-lock holder before terminating it.
-The offline evaluation harness lives under `apps/server/src/evals/`. It scores versioned synthetic manifests without network calls:
+### 4. Start all local processes
```bash
-pnpm --filter server test -- --run scorer
+pnpm dev:local
+pnpm --filter server worker
```
-The opt-in live command requires `OPENROUTER_API_KEY` and reports harness, schema, prompt, model, token, and score metadata:
+Upload one of the PDFs from `examples/invoices/`, then follow the complete
+walkthrough in [`DEMO_GUIDE.md`](DEMO_GUIDE.md).
-```bash
-pnpm --filter server exec tsx scripts/eval-live.ts
+## Demo modes and safety
+
+| Mode | Intake | Reset | Intended use |
+| --------- | ------------------------------------------------- | ---------------------------------- | ---------------------------------------------------------------------- |
+| `private` | Arbitrary authenticated PDF uploads | Requires `DEMO_RESET_ENABLED=true` | Local or access-controlled hosted walkthrough |
+| `public` | Server-known fixture IDs only at the API boundary | Enabled for the public demo | Synthetic public deployment after a fixture-picker client is available |
+
+Current browser intake is file-upload based. Keep `DEMO_MODE=private` for the
+hosted browser walkthrough unless the deployment provides a fixture-picker
+client for the public fixture API.
+
+Recommended safety settings:
+
+```dotenv
+DEMO_EXTRACTION_QUOTA_PER_ACCOUNT=20
+DEMO_EXTRACTION_QUOTA_PER_IP=5
+DEMO_QUOTA_PERIOD_HOURS=24
+DEMO_INTAKE_RATE_LIMIT_MAX=20
```
-Live extraction is never part of the CI quality gate.
+## Configuration by concern
+
+### Database and runtime
-## UI Direction
+| Variable | Purpose |
+| ------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
+| `DATABASE_URL` | Full PostgreSQL connection string; use direct Neon URL for Mastra |
+| `DATABASE_HOST`, `DATABASE_PORT`, `DATABASE_USER`, `DATABASE_PASSWORD`, `DATABASE_DB` | Components used to construct a URL when `DATABASE_URL` is absent |
+| `HOST`, `PORT` | API bind address and port |
+| `CORS_ORIGINS` | Exact browser origins allowed to call the API |
+| `DATABASE_POOL_MAX` | PostgreSQL pool size |
-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.
+### Document storage
-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.
+| Variable | Purpose |
+| ----------------------------------------------------------------------------- | ------------------------------------- |
+| `DOCUMENT_STORAGE_ROOT` | Local private document root |
+| `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME` | Enable private Cloudflare R2 storage |
+| `DOCUMENT_MAX_BYTES` | Maximum document size; default 10 MiB |
+| `DOCUMENT_MAX_PAGES` | Maximum PDF pages; default 5 |
-## Development Guidance
+### Extraction
-Key implementation rules:
+| Variable | Purpose |
+| ------------------------------------------------------- | ---------------------------------------------------- |
+| `GOOGLE_AI_STUDIO_API_KEY` | Preferred direct Gemini provider; server/worker only |
+| `GOOGLE_AI_STUDIO_MODEL` | Gemini model; default `gemini-2.5-flash` |
+| `GOOGLE_AI_STUDIO_TIMEOUT_MS` | Gemini timeout; default 60 seconds |
+| `OPENROUTER_API_KEY` | Optional alternate provider |
+| `OPENROUTER_PRIMARY_MODEL`, `OPENROUTER_FALLBACK_MODEL` | OpenRouter model chain |
-- 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.
+Without a provider key, processing uses the deterministic extractor path used
+for offline development and tests.
-## Recovery And Operations
+### Demo controls
-- 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.
+| Variable | Purpose |
+| ----------------------------------- | ----------------------------------------------------------------------------------- |
+| `DEMO_MODE` | `private` for arbitrary authenticated uploads; `public` for fixture-only API intake |
+| `DEMO_RESET_ENABLED` | Allows the authenticated Administrator reset in private mode |
+| `DEMO_EXTRACTION_QUOTA_PER_ACCOUNT` | Per-account extraction quota |
+| `DEMO_EXTRACTION_QUOTA_PER_IP` | Per-IP extraction quota |
+| `DEMO_QUOTA_PERIOD_HOURS` | Quota period |
-## Deployment
+Only `VITE_` values may enter the web bundle. Keep all database, R2, and model
+credentials in the server/worker environment.
-`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.
+## Development
-The web image requires `VITE_API_URL` at build time because Vite embeds it in the static bundle:
+### Commands
```bash
-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 \
- .
+# Run applications
+pnpm dev # Docker Compose stack
+pnpm dev:local # Direct web and API processes
+pnpm dev:web
+pnpm dev:server
+pnpm --filter server worker
+
+# Quality gates
+pnpm lint
+pnpm typecheck
+pnpm test -- --run
+pnpm build
+
+# Focused checks
+pnpm test:web -- --run
+pnpm test:server -- --run
+E2E=1 pnpm test:e2e
+
+# Database
+pnpm --filter server db:generate
+pnpm db:migrate
+pnpm db:migrate:test
+pnpm db:push # Disposable local exploration only
+pnpm db:studio
+```
+
+Review generated migration SQL before applying or committing it. Do not reuse
+production data for test databases.
+
+### Repository layout
+
+```text
+trestle/
+├── apps/server/ # Hono API, worker, Drizzle, repositories, tests
+├── apps/web/ # React SPA, routes, features, and UI
+├── examples/invoices/ # Synthetic PDFs for the demo walkthrough
+├── config/ # Shared TypeScript configuration
+├── docker-compose.yml # Local development stack
+├── docker-compose.prod.yml # Production-shaped template
+├── DEMO_GUIDE.md # Click-by-click scenario runbook
+└── README.md # Project overview and setup
+```
+
+### Testing boundaries
+
+- Web tests use Testing Library and MSW at the network boundary.
+- Server route tests use Hono clients and fresh in-memory adapters.
+- PostgreSQL, queue, workflow, and storage semantics require integration tests.
+- End-to-end browser scenarios run with `E2E=1` against a running stack.
+- Live model evaluation is manual and is not part of CI.
+
+## Architecture at a glance
+
+```text
+Browser SPA
+ │
+ ▼
+Hono API ───────► PostgreSQL (authoritative business state)
+ │ ▲
+ ▼ │
+Private document store │
+ │
+Node worker ─────────────┘
+ │
+ ├── extraction provider
+ ├── Mastra workflow checkpoints
+ ├── Graphile Worker jobs
+ └── simulated Accounting System
```
-Review CORS origins, production cookie settings, database backups, R2 least-privilege credentials, and the `DEMO_MODE` setting before deployment. Set `DEMO_MODE=public` only for synthetic demonstrations with bounded quotas; use `private` (the default) for any real document processing. Never expose `OPENROUTER_API_KEY`, database credentials, R2 secrets, or session material to the web bundle.
+The API and worker are separate processes from the same server codebase. The
+worker prepares documents, extracts untrusted model output, validates it with
+deterministic rules, resumes suspended workflows, and performs idempotent
+posting. Mastra checkpoints execution; PostgreSQL owns financial authority.
+
+## Deployment checklist
+
+Before publishing a synthetic demo:
+
+- Use a separate managed database and private document bucket.
+- Use HTTPS and set exact production `CORS_ORIGINS`.
+- Use a direct/unpooled Neon URL for migrations and Mastra bootstrap.
+- Pass provider keys only to API/worker server environments.
+- Keep `DEMO_MODE=private` for arbitrary uploads, or provide a fixture picker
+ before switching to public fixture-only mode.
+- Set extraction quotas and a provider spending limit.
+- Verify Administrator reset, AP posting, Finance approval, duplicate handling,
+ and rejection using [`DEMO_GUIDE.md`](DEMO_GUIDE.md).
+- Never expose database credentials, R2 secrets, model keys, sessions, signed
+ URLs, or real documents.
+
+## Known boundaries
+
+Trestle does not include registration, multi-organization tenancy, email
+ingestion, batch upload, vendor or Purchase Order creation, three-way matching,
+currency conversion, real ERP integration, payments, billing, mobile apps, or
+autonomous financial decisions.
diff --git a/examples/invoices/README.md b/examples/invoices/README.md
new file mode 100644
index 0000000..0e0bf2b
--- /dev/null
+++ b/examples/invoices/README.md
@@ -0,0 +1,22 @@
+# Synthetic invoice examples
+
+These PDFs are fictional and contain no real supplier or customer data. They
+are intended for the walkthrough in [`../../DEMO_GUIDE.md`](../../DEMO_GUIDE.md).
+
+| File | Intended scenario |
+| ---------------------------------------- | ------------------------------------------------------------------------------------ |
+| `clean-acme-invoice.pdf` | Clean AP review, matching `Acme Supplies` / `PO-1001` |
+| `acme-clean-invoice-002.pdf` | Second clean invoice; use it for high-value Finance approval |
+| `acme-duplicate-invoice.pdf` | Different bytes but same invoice number; produces a business-duplicate finding |
+| `acme-missing-po-invoice-003.pdf` | Missing Purchase Order; demonstrates approval-required or warning-only policy |
+| `acme-currency-mismatch-invoice-004.pdf` | EUR invoice against USD `PO-1001`; demonstrates a currency-mismatch blocking finding |
+
+## Duplicate test
+
+1. Upload `clean-acme-invoice.pdf` and let it finish processing.
+2. Upload `acme-duplicate-invoice.pdf` to create a different-byte business duplicate.
+3. Upload `acme-duplicate-invoice.pdf` again to test exact content-hash duplicate intake.
+
+The second upload of the same bytes should link to the existing case rather than
+creating another case. See the full role-by-role steps in
+[`DEMO_GUIDE.md`](../../DEMO_GUIDE.md).
diff --git a/examples/invoices/acme-clean-invoice-002.pdf b/examples/invoices/acme-clean-invoice-002.pdf
new file mode 100644
index 0000000000000000000000000000000000000000..a3f8c0f0153fe744bac8af35958cda36a5b31c13
GIT binary patch
literal 2052
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
K00`vh=JY3}#J_?7
literal 0
HcmV?d00001
diff --git a/examples/invoices/acme-duplicate-invoice.pdf b/examples/invoices/acme-duplicate-invoice.pdf
new file mode 100644
index 0000000000000000000000000000000000000000..f9f0c0630121c839bcdb9272799f2fb6f5c1de54
GIT binary patch
literal 2163
zcmai#X*3iH8^^O`nIcP=ij28ttYeI2rV)uOjdkoKTxPkJF~bd?+=~>SCKhKB%`8^PAh;FDpGL4C;3#+k0B%Ph|3V7{ArSh8fK#Uc5i}~nKNJvM
zQtB~yZP7w%im%1CYC99v-ezll*6w}R6^U%6fW(!gR3cr+e2rfcd@`?pa_dhc+1{}W
zZ|aTi`f`+0`Lo$8&ProSMczo8X7fy~*pp0)6;
z%W{3_NvVXHL%E^;*C#=#|pLn2|X{m
zyS~oG<~@o9CVbI0ItGA;4V5?_X}4vuhJZR^cj)@fCft|iCSUY^Bi=aik_??WMR;`k
zI{(JF{Gc9dv@Bh=gq|gtBT?3X%+T;a|99Jo*SbF8LA4)9(d1l4dsHKWOJ9`y0Qp8
zUyRdc)sR^En{mm0?F+BPdNX@W)%+G==ZFKYSL(YP5K*~jFiyfSwBlcSavl4j#C@q8<}b)Mc_dB=Dfj@BGHmTiK;-z3<4jhWb*pif;cK042fv@^rBS1Y&CJTIEO}d
z8^PEiJK`_4$#5JUXZ;k>v4zBw@b|l`J*g6{N#i)3X744Du$aFPX2-2W0zc(CB(hnZ
z3{8y?fzo!}jd5lLNjIC>kfQRcuKnKfM6F&^bSE;mtj;o-BW&Mgy@*>4F4BB)*bDBH
zlz>b-!Zx*^t_w{D;04{Tj=T-DoUGJN`u6jB3ymu?b?`H`;P`r_1)n<+-t05mn&c7;
zA%%LR^QBVbCz_fcbC$&f1)@jXL`v2CS|{XvV~}`3o6?NUpfqk-nSJ*_I#Yc(%RL)-
zJ|DIzopyKLeWRw7<^hhhsdT8qYGGjtv6c^qV+p==-}G1W3vw(hO6vB)B8{$|g|`L+
zdk=S^CdeUJ|DT9~J}Rfwy|mkH`VB1@xDQ4VUY1Wf7?iZ@8T9(mL`=|PwFofW(PFW{
z;SteJ%dA5;TmyqHa{@n{cN0dtE0}dKB@8W&8i4^y-h4J!CMc06&nA(w*1_hb&+Gl`
zDAi>WhR&qq8zG7AfxtfRv4MN$y>##Cq5B$fA)!s;g}_aI_l`u-3SIxaCFk8jqE{cb
z2FTL_XDnH$9c
zJf+(JEid-@iQKZo385(}N5FIL$hQW0A2SCRmQOR3gv+m+UMy}Fe=8#8wbni$@Jx>)
zXNU@VY@uB6%eoi;V`M*r#9P+ZwTFg|;O8ubcSWbS^Iro9Wc+{0vzLA672w4G1D9(|
z7y$%#3yO!3
zdMfW0b@4po*6vP?PmI^p?-La}zxswwMfF^HOf}8eU{4vAe(deXtkX^Iw=LAS!$ctS
z`kw{DTZ23v6WYT$ncD#ZUHeXtrtJ$OF1F?zEnl`z3D~t#Xc9O^cx_r%JW_NBxn_9jQ>8lm&8ymlU>EwWTweze&Yj}vM?zWpFemnc47r-s}N1nUS6UJUUdnNrz(Ry;N$8e$SSxs8?q|i(1VT^RNU{^XK`E;JVDs)YrSFm5@
zWKsJX;}S+!#$SwDy#!mLk46+>iPBE1bq{fcqf&P&93UxX>>6fHL+yTTU)2m3(NxCS
zJ9cwJ@3W?5<4p4-!Qv_wO6wQH;DvEwNQ&gx6;h9AmGWsgen^aXEK-$#D0H%7N}C)|
zI|~rT#Rh~@>|j7}CXDjfP+`W1{RGP0%&&GQYb`vr)$GQB?(G9GiL>pC!H2
z{k%LJ{J2EheOkIuxCyYt!2_LDq4C+{b2^;~ZQEXOzHM>KNB+v!$eVY6>w3!0jBTzT
zeiIib+I4D!>xt+TxobamVLP7dm%?mn&5mMUf|W^JH$IxN5gRqOIWhFfGR8f0F(J@+
zJIKLc@Io`0?6XanDnU+c3e76Rw#E~Bm;9b>J|F#ovC;oBwo6bf0ff*6z?~@+8c1(%
z;tYUe$V3WA|L^>VFZ*xjdpZ+{{}|ms9{~3Nd4mvpB6=X7?;?0Y^j<@EPkyid_h*-T
z@<4hY=%CPpC^2iBE%P(6)s#j$0Yn{*`a*xp14~~zZ`BQ1p)V<9J
Nw4nh20Ouej
literal 0
HcmV?d00001
diff --git a/examples/invoices/acme-missing-po-invoice-003.pdf b/examples/invoices/acme-missing-po-invoice-003.pdf
new file mode 100644
index 0000000000000000000000000000000000000000..210ae6893912dede967fd3538a39f8819bc98298
GIT binary patch
literal 1991
zcmai#c{JPU8pqKx{7UUxX+;r?5O3&Mp&0x%c=v5&wK(0CBU9ve(R+oD5pC^P{12_1tWgo8|si~)0VAdY}X2S)>>
zsygqHqW88^+ykv1`-=O^=G58=M|NUgh6tIP%U|(>X#6P=>yHOj$g9I8gxiCz$fnZJ
z&M~vy#LdJm3*jZ5U!dILV0VS1L-gJk&P~y@w8NFoU5lr*qxbUzTJUy9rz^{j5wMgq
zjza@Pn{$#yFlYHY5ir>4$7-iWyh=?Fq;o32$8E
zdy&Jd>&4zP#!c3)oy%6jy_-06(5$1h$tXmX7d$f1VKr65o~=hNPsaIy-=gyBKKIoR
zEO}|}=Q_~Cv8@Xo(;4yEbVHwE)pQ06n@Ukl3Q0UC>fD8xv4WueZ9Q}}A-XZ+@KWmP
zWcbLS5M~u&dd0R|E@Zizv^4Y!TMuoO&uT^W*n6MKlZRqi3~S-Rye0X8S6<0N!-Xtw
z$|{Xa_j~Y4oy?gL$W!kKbb8^Ud2s>NX!eh?TUh!O;|L#|u(hj>f6OA`L@u>6
zU@y5VE*okJa~9RTdE5KwltI?S8v{;H-dJD`yz^j8amnlXxVGtOgC{HxE9aAv)iyh%
zFBZyi!irQxkAhycvUIniuSAfX9G4u>m;#}+T*{cH=GW?1bfid?+5wb1;sm69g>;zA
zAU($iW81d9#y0k^EXpSoRxEeOX1b=Fp}EKXz8vkG^0|${oSU3K%r4$kDnsGXHGBaR
z`dRZ$>PV8d3(+}N{TYT!^tNCe_z+5cOeS=|Oy>)Z%67BdFR*vLLjRYHe_HpAT_2s&w8?34
zxU~>)5|)7BeG0x8M47C}eF%{sFulaIM%~mO`JH405_yzeR^axEAAdJJ?DYkcg~)Me
z?ZkcFZZSdOE}_-Eg5ape2t$6!m(eRPa1*DAU4RFvT?hys*qd)tJ#=zodU9-kX;gRNtX{T5^jnis2M%
z`sH9(;Npl5*m5Sd&AeAMd1qK2j~LkR??~e<$cA_W=or+0hx=H4BLZOj|Ipx>7>fo$
z;31J7glGWb1~UAH5PTc~q;K#SHw>Zr|HF+bPnRf;rC9HZ_iD4*$(N(+qrYWZN^rDG
z$H`KDeo2?H;+`3v*zVe+-qw~9vd{T(B=knR>70=%Zlmwlf(icNcQr1&0eJIb;s+S>
zZPCqw&8@qE?OG5jC&{B!sd8#4)#yT(*6wc_WAtc_X<${A6nti@{_#4E6XJk3zo8^n
zz7o?OFdaqgxdoBo5$2I{739I}q{Y;L!!hh%XF7$388c^pxE>oxW4h%Ms~VJ^M2y|#
zJPpwi&ED<#%OG~fPS0{Tel3_fPcqu1w5?@~)G|RF?Miu-~X1A7KIr%PQW>%4h
z5~SKMcAxK~OmhFC$yti8?PRjn+DTtuo5FmR+cpB9>MO!CqEdsbRNSkae{#$-V4-F%
zh^K^XJV3xzl`|g(4>C~e$}_ztA4gRk
zg&dK&9mBc(!Gp#ltk4H>2y11{)k1Nr4#H}U^bj35`taa*-swNH-_SN)*wr1C|C)cN
zq^GhgjT||V`<>?F2*G#)34i=E)W}#5P*bym+y4j0-;@>r
literal 0
HcmV?d00001
diff --git a/examples/invoices/clean-acme-invoice.pdf b/examples/invoices/clean-acme-invoice.pdf
new file mode 100644
index 0000000000000000000000000000000000000000..cf92e87014eb81d53ab12c1f5d26c7dfa06b9906
GIT binary patch
literal 1663
zcmai!dpOg39LHVe9DeSp(1l+vNy;{x%hrq*bH7Ax71^}88#4?k=2FKs)K+d~D%IRO
z!mx4@<%FD))s(W3JB3a{M^ESJ={!%*bH0ClzTeODyg%PRUaya~vju)HT;CYbPFg3-2fkv_*`B9FO0H{4F%%2tj8p90%EEb5QQAvbQfL?Uf
zT@a*naAU%y>I?p*++Lo#LTR^z`U%DN;%v8b>4(~9V~X>Ufs^)8F1VeMd9dK=`T^dc
zoJVt#&t|oVzUYVZW2`s*g2}?6fjhjDE8!7~!qF#|&JGUg6`D^PKVxdLn_~{BN>v*K
zW%u#EU|XKC*};Z8l@OYm-hpn2Si}rZwpsZe*|}5>y8n0u-{4{Lq3b0HA)l0(igcJI_CADLo&S$GzxT!D=*{G3j+}sw0hkCA$
z7@b(WE)FMhI9_T=?{1I|+r-PPa`|F-6>mPA^49#Ia#s$YZ*iDU(hv^Q@*vhCFT`vL
zU?!?sVr9Nf5J8Emk94xBU31ymZfVrjE$6TNl~N$wi0rm0y}&iZ%F`{z>Yo;k&)J^K
zEb=G5xTs>N+p6nD>?LK~dUYdtkkVFKbk!QV!OlGfl6z}S_gA+9J$Jbr^Ndid*&Znq
z$)V&zhBCF`zPF#M&ujJ;mUk;)s?)s+O_S)p=V`~ka@6;XW34}*rpOu{x;*BKCqHv}
zKtfyMtenMbHwPpi(Cz<#cH7k76@|uePm{e}HqMmV6Vf9UP`m6Z`>*Leo!QS0+)*yM
zVTXPSZhsqhsw#4{?tr)v!{++)xIj+Z<++=vmunEyH92BzF@EN7__FT(PYE>`EGHWDroB9p?Bf_3AjA*k5p^mn3ad
z`F*r7C!!bkl4V53J{?Vu)<8U_rIg4CE@{lQ*L0ARvwPkk2xC&*{sW=SIKAdqQ~~ID
zdZD!Oxje;$tSfOS;|igJ@2R8u7Cv8ZTp!|G*|&gdj2i#&$y|D?7&@Vxl~5UrR9t~6
z&dsitL=-_<#tFXe3)5u_P4B~XhlOwV<~(DY1?O1L1EjFy|5CkPeN_Q4`9CKP#fFnW
zs1q^Bl@9YLe7?ocCXAOiVa7(*oLKf?4MvI`zGm*IW(cvNc&2}a5X+IsT+rL~b-
zsZ6F?(PB;Gp6KU`%!MHj#49bWN$tl+;KV)S`S|Kw_sh;+oJCfaF6bn6g@NWO
zY=zL#yNb!#BqEyFfKwAuruL*V6aXmt_$OtJc&*%AQJ8CAK-rl$0$n2|f5CH4H4{jO!uvS1{u=pD5tyDu)M
zBNI|6dN?mCz_1A+LL9wZk6Z{bWz-tIZi?F>I;p_;P%bjg)5>}!$SqJAJ5hsS8r4cn
zTTkX1#P@cnY<+Y?t~iC^YZdmyy3X(HhZ5CF@f~L
z$beIfnGoH3jL7=9`dNULT>`D^IG)D+70s;kzr2slN*B9!-zZd2<;2m4Hr-|^I&Iso
zJajAO?6sC%6#1A0a=x?jNX3gz%onE!TZL&Zr!cy#3+k{h^kaAOYjxAny4K+(XeIMh
z%1=;4{eYrtU>pgA!vLrYg+c=j);lf$)FzBf0TJKopYHE(ta`eT$luXK8URoa@CXQB
z4>16}zlIzq(bpef>+$RNANTyG_BKTkP*S{2`az~sdd(?2i(5GoWQMGx{!#9TEpk_H
gwLT`WR@~$#e~+XQs5ClteJ~tpXb5O)TRP$Y0jlWdumAu6
literal 0
HcmV?d00001
From abeece112565dfd44e500bbd059cbd1ebc2cfcac Mon Sep 17 00:00:00 2001
From: Muhammad Fadhil
Date: Sun, 2 Aug 2026 15:21:09 +0700
Subject: [PATCH 4/5] chore: ignore local agent artifacts
---
.gitignore | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/.gitignore b/.gitignore
index cc4a5d9..61ca812 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,3 +38,12 @@ coverage
.cache
tmp
temp
+var/
+
+.agents
+.cortexkit
+context/
+docs/
+CONTEXT.md
+AGENTS.md
+graphify*
From 27d521679dece178bc6c3eac93ac3751e53d6e04 Mon Sep 17 00:00:00 2001
From: Muhammad Fadhil
Date: Sun, 2 Aug 2026 15:21:45 +0700
Subject: [PATCH 5/5] chore(server): keep disabled development limiter
type-safe
---
apps/server/src/lib/create-app.ts | 1 -
1 file changed, 1 deletion(-)
diff --git a/apps/server/src/lib/create-app.ts b/apps/server/src/lib/create-app.ts
index 83a21e7..0d5a497 100644
--- a/apps/server/src/lib/create-app.ts
+++ b/apps/server/src/lib/create-app.ts
@@ -9,7 +9,6 @@ import { secureHeaders } from 'hono/secure-headers';
import { notFound, onError } from '../middlewares/common.js';
import { validateRequestOrigin } from '../middlewares/origin.js';
import { pinoLogger } from '../middlewares/pino-logger.js';
-import { rateLimit } from '../middlewares/rate-limit.js';
import { env } from './env-config.js';
import { errorResponse, HttpStatus } from './response.js';