Skip to content

Commit 9e9663b

Browse files
DylanMerigaudclaude
andcommitted
Realign the README + brief with the code; add the flagship e2e
The README still described the pre-link architecture, so it now matched the code: - the loop is closed (the derived workflow drives the run); the API is oRPC, not app/api/* routes; the run streams as a typed event iterator, - the seeded-scenarios table shows the department gate (INV-2044), the amount-gated manager (small clean → straight-through, material clean → human), - the PO is pulled from QuickBooks (the ERP read seam, mirror of the HRIS one); QBO env vars documented; the "fake ERP" caveat narrowed to the post side, - ClientProfile/profileId (deleted) removed; the project layout updated to oRPC. Added e2e/onboarding-to-pipeline.e2e.ts: the flagship loop through the real browser (discover → switch tabs, state survives → run INV-2044 → the Product department gate fires → approve → posts) — the parcours unit tests can't cover and the one most likely to regress. Updated approval.e2e.ts for the renamed tab. AGENT_BRIEF.md gained a "current state" section so a fresh session doesn't re-derive the link work. Gate green, 189 tests, both e2e pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 454a929 commit 9e9663b

4 files changed

Lines changed: 134 additions & 24 deletions

File tree

AGENT_BRIEF.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,32 @@ through that workflow. This file is the context a fresh session needs so it does
77
re-derive or break conventions. (`.product/*.md` has deeper strategy notes but is
88
gitignored, so it may be absent in a worktree — this committed brief is the source.)
99

10+
## Current state (the two surfaces are LINKED — done, don't re-derive)
11+
12+
The "complete loop" is built and on `main`. Key facts a fresh session must know:
13+
14+
- **The derived workflow drives the run.** `AppView` holds the active `ApprovalWorkflow`
15+
in client state; onboarding pushes it up (discovery + each approved edit), the
16+
Dashboard reads it and `usePipelineRun` sends it in the oRPC `run` body. Absent (cold
17+
visit) → the default DAG from `lib/client-profile.ts` (`workflowFromPolicy`) stands in.
18+
Both tabs stay MOUNTED (hidden, not unmounted) so state survives a tab switch.
19+
- **Manager gate is amount-conditional**, not `always`: fires on any exception OR a clean
20+
bill over the manager floor ($1,000). Aligned in BOTH `lib/onboarding.ts` (derived) and
21+
`workflowFromPolicy` (default). So small clean → straight-through, material/flagged →
22+
human.
23+
- **Department lives on the PurchaseOrder**, carried through `MatchResult` into the engine
24+
(`lib/approval-run.ts` reads `match.department`). The derived "department review" gate is
25+
a parallel ROOT scoped to `department == "Product"`; PO-7744 (INV-2044) is the seeded
26+
Product PO. A pulled (QBO) PO has dept ""; `loadRunBundle` overlays the seeded dept.
27+
- **Multi-wave HITL**: `usePipelineRun` accumulates decisions and the resume sends their
28+
UNION (the stateless run rebuilds the DAG), and it re-detects `awaiting` on a resume, so
29+
a gate behind another gate re-pauses instead of posting.
30+
- **Live workflow graph** in the Dashboard: the same `WorkflowGraph` onboarding draws,
31+
lit by the run's per-step statuses (`readRunGraph` pulls `approval.workflow` + steps from
32+
the trace), shown between the document scan and the text timeline.
33+
- **e2e** (`pnpm e2e`, local-only, needs keys): `approval.e2e.ts` (HITL on the default) +
34+
`onboarding-to-pipeline.e2e.ts` (the flagship loop: discover → dept gate → post).
35+
1036
## Hard conventions (non-negotiable — the user is strict about these)
1137

1238
- **No `as` casts.** ESLint `@typescript-eslint/no-unsafe-type-assertion` is ON.
@@ -78,4 +104,6 @@ pnpm typecheck && pnpm lint && pnpm knip && pnpm test && pnpm format:check && pn
78104

79105
## Your task
80106

81-
See the task brief in this same folder (`TASK.md`), written for this branch.
107+
Ask the user — the link-workflow / department / live-graph work (see "Current state"
108+
above) is done and merged. There may be a `TASK.md` in this folder from a past branch;
109+
treat it as scaffolding, not a live instruction, unless the user points you at it.

README.md

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
A **procure-to-pay** product in two halves. **Onboarding**: connect a client's HRIS and an agent derives their approval workflow — who signs off on what — resolved to real people from the org chart, with the data-quality problems flagged for a human to fix. **Operations**: a vendor PDF comes in, gets extracted, matched, and run through that workflow, with a live execution trace you watch as it happens.
44

5+
The two halves are one loop: the workflow you derive and edit on the left is **exactly** what routes the invoice on the right. Edit a gate in plain language, switch to the pipeline, run a bill — it pauses on the approvers and conditions you just defined, and the same graph you built lights up as the run walks it.
6+
57
AI is used in the places it earns its keep, and nowhere else. **Extraction** reads the messy vendor PDF into structured data (vision). **Onboarding discovery** maps an org's titles to approval authority (genuinely fuzzy judgement). **Investigation** judges a flagged exception against unstructured records and recommends. Everything else — matching, the workflow engine, reconciliation — is deterministic code, because a payment decision must be exact and repeatable, never a model's guess. Nothing posts until a human approves. Built with [Mastra](https://mastra.ai).
68

79
The differentiator vs the workflow builders (Ramp, Zip, Pivot): you don't draw the approval graph on a canvas by hand. **The agent derives it from the HRIS, and you maintain it in plain language** — "above $25k also require CFO approval" — with a preview you approve or revert.
@@ -27,7 +29,7 @@ The result is a **proposal**: you review the resolved approvers and the flagged
2729
**Conversational editing, with preview → approve / revert.** Tell the agent what you want and it proposes a rewrite; the graph shows the diff (added / changed / removed gates) and **nothing is applied until you approve** — the pipeline only ever runs the workflow you've approved.
2830

2931
- The DAG **structure** is a deterministic template; the agent only makes the fuzzy calls (which title fills which approval level → which person, what threshold, plain-language issue notes). Code assembles those into a Zod-validated `ApprovalWorkflow`. An edit that would produce an invalid graph is rejected, never applied.
30-
- `lib/onboarding.ts` (derive + assemble), `lib/onboarding-model.ts` (the structured-output call), `lib/workflow-edit.ts` (conversational edits + diff), `app/api/onboarding`, `app/api/workflow/edit`.
32+
- `lib/onboarding.ts` (derive + assemble), `lib/onboarding-model.ts` (the structured-output call), `lib/workflow-edit.ts` (conversational edits + diff). The API is one typed **oRPC** contract ([`lib/orpc/`](lib/orpc/)) — `onboarding` and `editWorkflow` are procedures on it, not hand-rolled routes, so a response-shape change is a compile error on both client and server.
3133

3234
---
3335

@@ -57,7 +59,7 @@ flowchart LR
5759
```
5860

5961
- **Extraction (AI)** — the vendor's invoice PDF is read by a vision model into a schema-validated `Invoice`. The extracted invoice is what the rest of the pipeline runs on — matching joins the extracted lines against the PO, like production.
60-
- **Matching** — a 2-way (invoice ↔ PO) or 3-way (invoice ↔ PO ↔ goods receipt) match, returning `clean`, `exception`, or `duplicate`.
62+
- **Matching** — a 2-way (invoice ↔ PO) or 3-way (invoice ↔ PO ↔ goods receipt) match, returning `clean`, `exception`, or `duplicate`. The PO is **pulled from the client's ERP** (QuickBooks Online, [`lib/erp.ts`](lib/erp.ts)) — the procurement mirror of reading their org from BambooHR — so an incoming invoice is matched against their real open POs. Read-only; nothing is persisted.
6163
- **Investigation** — runs only on an exception, and the one open-ended agent in the operational path. A number ("9% over the PO") doesn't tell a reviewer whether it's a legitimate price increase or an overcharge; that lives in unstructured records, and which records matter depends on what you find. The agent **chooses** which tools to call, reads them, and recommends. It decides nothing about the money.
6264
- **Approval workflow engine** — the invoice runs through the client's derived DAG ([`lib/approval-engine.ts`](lib/approval-engine.ts)): each gate's condition is evaluated, the active gates **pause for a human** (several can be pending in parallel — a fan-out), and the bill posts only once **every** active gate is approved. One rejection blocks everything downstream. A clean invoice trips no gate and goes straight through.
6365
- **Reconciliation** — posts the vendor bill and double-entry GL to the ERP, only once cleared.
@@ -66,32 +68,38 @@ The split-view dashboard shows the **invoice queue** (color-coded by outcome) an
6668

6769
### Seeded scenarios
6870

69-
~10 realistic invoices, including three deliberate edge cases:
71+
~10 realistic invoices, covering the edge cases and the routing levers:
7072

7173
| Invoice | Scenario | Outcome |
7274
| --- | --- | --- |
73-
| `INV-2042` | Price mismatch — steel bar invoiced ~9% over the PO | `price_variance` → manager gate → **pauses for your decision** |
75+
| `INV-2042` | Price mismatch — steel bar invoiced ~9% over the PO | `price_variance`investigator → manager gate → **pauses for your decision** |
7476
| `INV-2048` | Quantity mismatch — invoiced 100 units, only 80 received | 3-way receipt check → manager gate → **pauses for your decision** |
7577
| `INV-2041` (re-send) | Duplicate — same invoice number twice | `duplicate`**blocked**, not posted |
76-
| 6 × clean | Clean 2/3-way matches | no gate → straight-through |
78+
| `INV-2044` | Clean, but the PO belongs to **Product** | clean match, yet the **department review** gate fires → pauses for the Product head, in parallel with the manager |
79+
| `INV-2040` | Clean, $730 | under the manager floor → **straight-through**, no human |
80+
| larger clean (e.g. `INV-2049` $9,360) | Clean 2/3-way match over the floor | a human still signs a **material** bill → manager gate |
81+
82+
The manager gate fires on any exception **or** a clean bill over a floor ($1,000), so small clean invoices post straight through while material or flagged ones get a human — the standard AP control, and the same behaviour whether or not you've run discovery (the un-onboarded default reproduces it). The department gate is the differentiator: a buying department lives on the PO (pulled from the ERP), and a workflow can route a department-specific review on it.
7783

7884
---
7985

8086
## How it's built
8187

8288
**AI at the edges, deterministic code in the core.** The ends are language/perception/judgement problems — reading a PDF, mapping titles to approval authority, judging a fuzzy exception — so they use a model. The middle (is this a 9%-over variance? which gates apply? did every gate approve?) is arithmetic and graph logic, so it's pure, unit-tested functions ([`lib/matching.ts`](lib/matching.ts), [`lib/approval-workflow.ts`](lib/approval-workflow.ts), [`lib/approval-engine.ts`](lib/approval-engine.ts), [`lib/erp.ts`](lib/erp.ts)): exact, auditable, identical on every run. An LLM never decides a payment amount.
8389

84-
**The conditional approval workflow.** Approval isn't a single tier — it's a DAG of conditional gates ([`lib/approval-workflow.ts`](lib/approval-workflow.ts)): each step carries a `when` condition (amount / variance / department / verdict, combinable with all/any) and parallel `next` edges. The engine ([`lib/approval-engine.ts`](lib/approval-engine.ts)) walks it per invoice with collect-all semantics: a skipped gate is a transparent pass-through, several gates can pend at once, one rejection blocks downstream. The per-client config lives in a `ClientProfile` ([`lib/client-profile.ts`](lib/client-profile.ts)); an un-onboarded profile derives a behaviour-equivalent DAG from simple thresholds, so the old flat tiering is a strict subset.
90+
**The conditional approval workflow.** Approval isn't a single tier — it's a DAG of conditional gates ([`lib/approval-workflow.ts`](lib/approval-workflow.ts)): each step carries a `when` condition (amount / variance / department / verdict, combinable with all/any) and parallel `next` edges. The engine ([`lib/approval-engine.ts`](lib/approval-engine.ts)) walks it per invoice with collect-all semantics: a skipped gate is a transparent pass-through, several gates can pend at once, one rejection blocks downstream. **The workflow the onboarding agent derived is what the run executes** — it's passed into the run as input (held in client state, never persisted, so the run stays stateless), and the pipeline routes through it. When no workflow has been derived yet, a default DAG ([`lib/client-profile.ts`](lib/client-profile.ts)) built from simple thresholds stands in, so the pipeline works on a cold visit and behaves the same as a derived one.
8591

8692
**The HRIS adapter is real, captured, replayed.** [`lib/hris.ts`](lib/hris.ts) reads BambooHR (`bambooHris`, live HTTP) or replays a fixture captured from that same API (`recordedHris`); one `defaultHris()` factory picks live-vs-recorded — the only place that branch exists. The committed fixture in [`db/fixtures/bamboohr/`](db/fixtures/bamboohr/) is **real BambooHR output**, captured on a dated run via `pnpm hris:capture` — not a mock — so the demo (and CI, which has no key) runs on real data and survives the trial key expiring. `pnpm hris:seed` / `hris:reset` stand up a curated org in a sandbox (scoped by a dedicated Division, server-side, so reset only removes what it created).
8793

94+
**The ERP adapter is the same seam, on the procurement side.** [`lib/erp.ts`](lib/erp.ts) pulls a client's open purchase orders from QuickBooks Online (`liveQuickBooksErp`, real OAuth2 + SuiteTalk-style query) or replays a captured fixture through the **same** mapper (`recordedErp`); `defaultErp()` picks live-vs-recorded, the only place that branch exists. Everything QuickBooks-specific (the `QueryResponse.PurchaseOrder` wire shape, the token endpoint) stops at this file and `mapQboPurchaseOrders` returns the internal `PurchaseOrder` the matcher already consumes — swap QBO for a `netSuiteErp` of the same `PoSourceAdapter` and nothing downstream changes. The buying **department** is our own overlay (the ERP doesn't carry a vendor-facing cost-centre), so a pulled PO is matched on lines/prices and the department comes from the seed.
95+
8896
**The investigator agent.** [`src/mastra/agents/investigator.ts`](src/mastra/agents/investigator.ts) is a Mastra `Agent` with three tools returning deliberately unstructured records. It runs an open-ended loop — picks which tools to call, reads them, writes a recommendation (`likely_legitimate` / `likely_overcharge` / `unclear`) — and only *recommends*; the engine and the human gate own the outcome. Tools read the trusted vendor from `requestContext`, not model args, so the agent can't pull the wrong vendor's file.
8997

90-
**A real human-in-the-loop, statelessly.** On an exception the run pauses before reconciliation (`awaiting`) and the post doesn't happen until a human approves the pending gate(s). The demo never writes to the database, yet a pause normally needs a persisted run to resume — so instead the Approve/Reject click sends per-step decisions (`{ "director-review": "approve" }`) that recompute the cheap deterministic prefix and continue, gated in [`app/api/run/route.ts`](app/api/run/route.ts).
98+
**A real human-in-the-loop, statelessly.** On an exception (or a material clean bill) the run pauses before reconciliation (`awaiting`) and the post doesn't happen until a human approves the pending gate(s). The demo never writes to the database, yet a pause normally needs a persisted run to resume — so instead the Approve/Reject click sends per-step decisions (`{ "director-review": "approve" }`) that recompute the cheap deterministic prefix and continue. The run is the `run` procedure on the oRPC contract, streamed as a typed **event iterator**. Decisions accumulate across waves and the resume sends their union, so a workflow with a gate behind another gate re-pauses on the newly-reached gate instead of silently posting.
9199

92100
**Zod as the single source of truth.** Every shape is defined once in Zod ([`lib/schema.ts`](lib/schema.ts), [`lib/approval-workflow.ts`](lib/approval-workflow.ts)): it constrains the model, validates every boundary at runtime (`safeParse`), and its inferred types flow into Drizzle, the workflow, the stream, and the UI. **Env is typed too** ([`lib/env.ts`](lib/env.ts), `@t3-oss/env-nextjs`): everything reads `env`, never `process.env`.
93101

94-
**Streaming, relayed and adapted.** The route relays Mastra's `run.stream()` as NDJSON; a small adapter ([`lib/trace.ts`](lib/trace.ts)) maps raw chunks to a stable `TraceEvent` so the UI depends on our vocabulary, not Mastra's internals, and a junk chunk is dropped rather than crashing the stream.
102+
**Streaming, typed end to end.** The `run` procedure is an **oRPC event iterator** (a typed async generator of `TraceEvent | StreamDone`); the client consumes it with `for await`, no manual reader or cast. A small adapter ([`lib/trace.ts`](lib/trace.ts)) maps Mastra's raw `run.stream()` chunks to the stable `TraceEvent` vocabulary so the UI depends on ours, not Mastra's internals, and a junk chunk is dropped rather than crashing the stream.
95103

96104
### Stateless by design
97105

@@ -111,15 +119,19 @@ lib/
111119
onboarding.ts derive a workflow from an org (+ onboarding-model.ts)
112120
workflow-edit.ts conversational edits + diff (+ workflow-edit-model.ts)
113121
hris.ts BambooHR adapter: live + recorded, one factory
114-
client-profile.ts per-client config; flat policy → DAG bridge
115-
matching.ts · erp.ts pure, unit-tested decision logic
116-
extract.ts vision extraction (invoice PDF → validated Invoice)
122+
erp.ts QuickBooks PO pull: live + recorded, same factory + mapper
123+
client-profile.ts the default workflow (thresholds → DAG) when none derived
124+
matching.ts pure, unit-tested decision logic
125+
extract.ts · intake.ts vision extraction; the intake step that calls it
126+
orpc/ the typed API contract (router, schemas, client)
117127
schema.ts · env.ts Zod source of truth; typed env
118-
logger.ts · api-routes.ts · trace.ts logging; endpoints; stream adapter
119-
app/api/
120-
run/ · onboarding/ · workflow/edit/ · pdf/[id]/
128+
use-pipeline-run.ts · trace.ts the run hook (HITL, multi-wave); stream adapter
129+
app/
130+
rpc/[[...rest]]/ the single oRPC handler (run · onboarding · editWorkflow)
131+
api/pdf/[id]/ the one plain REST route left (binary PDF)
121132
db/
122-
schema.ts · seed-data.ts · client.ts · fixtures/bamboohr/ Drizzle + real fixture
133+
schema.ts · seed-data.ts · client.ts Drizzle + read layer (PO-pull aware)
134+
fixtures/bamboohr/ · fixtures/quickbooks/ the real captured HRIS + ERP fixtures
123135
config/eslint-rules/ custom lint rules (no-console, api-routes, …)
124136
```
125137

@@ -161,6 +173,7 @@ pnpm dev # http://localhost:3000
161173
| `DATABASE_URL` | **yes** | Supabase Postgres — use the **transaction pooler** string |
162174
| `DIRECT_DATABASE_URL` | optional | Direct (non-pooled) string for `db:push` / `db:seed` |
163175
| `BAMBOO_HR_API_KEY` + `BAMBOO_HR_SUBDOMAIN` | optional | Live BambooHR. **Without them onboarding replays the committed real fixture** — the demo and CI work with no key. |
176+
| `QBO_CLIENT_ID` + `QBO_CLIENT_SECRET` + `QBO_REFRESH_TOKEN` + `QBO_REALM_ID` | optional | Live QuickBooks PO pull. **Without all four the pipeline replays the committed real fixture** — demo and CI work with no key. |
164177
| `UPSTASH_*` / `KV_REST_API_*` | optional | Per-IP rate limiting; fails open without it |
165178

166179
> **Set a spend cap on the Anthropic key** — the deployed demo is public and the buttons call the model.
@@ -171,11 +184,11 @@ pnpm dev # http://localhost:3000
171184

172185
## What's next
173186

174-
A stateless demo with a fake ERP; the decision logic is pure, typed, and unit-tested. Production is additive, not a rewrite:
187+
A stateless demo; the decision logic is pure, typed, and unit-tested, and the read-side integrations (HRIS, ERP PO pull) are already real-or-replayed. Production is additive, not a rewrite:
175188

176-
- swap the fake ERP / integration stubs for real adapters (same interfaces),
177-
- live BambooHR (the adapter + a captured fixture already exist; a dev key with field-edit permissions unlocks self-serve seeding) and a second HRIS behind the same `HrisAdapter`,
178-
- add persistence and an audit trail (the `agent_runs` table is shaped for it),
189+
- the ERP **pull** is real (QuickBooks); swap the **post**-side stub (`fakeErp`) and the Slack/Jira integration stubs for real adapters of the same interfaces,
190+
- live BambooHR + QuickBooks (both adapters + captured fixtures already exist; keys unlock the live path) and a second HRIS / ERP behind the same `HrisAdapter` / `PoSourceAdapter`,
191+
- add persistence and an audit trail (the `agent_runs` table is shaped for it) so a workflow and a paused run survive a refresh,
179192
- wire real approver identity to the per-step gates,
180193
- accept real uploaded PDFs at intake.
181194

0 commit comments

Comments
 (0)