You're working on ledgerloop, a procure-to-pay demo built as a job/freelance
asset for Didero (procurement-AI). Two surfaces: an onboarding agent that reads a
client's HRIS and derives an approval workflow, and a pipeline that runs invoices
through that workflow. This file is the context a fresh session needs so it doesn't
re-derive or break conventions. (.product/*.md has deeper strategy notes but is
gitignored, so it may be absent in a worktree, this committed brief is the source.)
The "complete loop" is built and on main. Key facts a fresh session must know:
- The derived workflow drives the run.
AppViewholds the activeApprovalWorkflowin client state; onboarding pushes it up (discovery + each approved edit), the Dashboard reads it andusePipelineRunsends it in the oRPCrunbody. Absent (cold visit) → the default DAG fromlib/client-profile.ts(workflowFromPolicy) stands in. Both tabs stay MOUNTED (hidden, not unmounted) so state survives a tab switch. - Manager gate is amount-conditional, not
always: fires on any exception OR a clean bill over the manager floor ($1,000). Aligned in BOTHlib/onboarding.ts(derived) andworkflowFromPolicy(default). So small clean → straight-through, material/flagged → human. - Department lives on the PurchaseOrder, carried through
MatchResultinto the engine (lib/approval-run.tsreadsmatch.department). The derived "department review" gate is a parallel ROOT scoped todepartment == "Product"; PO-7744 (INV-2044) is the seeded Product PO. A pulled (QBO) PO has dept "";loadRunBundleoverlays the seeded dept. - Condition levers (
ConditionField): amount, exceptionAmount, variancePct, department, verdict, vendor, currency, matchType, exceptionCode.exceptionCodeis SET-MEMBERSHIP (== code= "the invoice raised this flag"), handled inevaluateCondition, not via the scalarvalueFor. The editor gets the realdepartments/vendors/currencies(vendors + currencies derived from the queue inAppView) so it only builds a gate that can fire; the "What can I change?" popover lists the levers + values. - Multi-wave HITL:
usePipelineRunaccumulates decisions and the resume sends their UNION (the stateless run rebuilds the DAG), and it re-detectsawaitingon a resume, so a gate behind another gate re-pauses instead of posting. - Live workflow graph in the Dashboard: the same
WorkflowGraphonboarding draws, lit by the run's per-step statuses (readRunGraphpullsapproval.workflow+ steps from the trace), shown between the document scan and the text timeline. - e2e (
pnpm e2e, local-only, needs keys):approval.e2e.ts(HITL on the default) +onboarding-to-pipeline.e2e.ts(the flagship loop: discover → dept gate → post).
- No
ascasts. ESLint@typescript-eslint/no-unsafe-type-assertionis ON. Narrow with a type guard (isRecordinlib/assert.ts), validate with a Zod schema, or usesatisfies.as constis fine. Genuine boundary casts get a per-lineeslint-disableWITH a reason (rare). - No non-null
!. UsenonNull(x, "why")fromlib/assert.ts. - No
any. Rule is ON. - Exhaustive switches end in
assertUnreachable(x)fromlib/assert.ts. - No
eslint-disablecop-outs. Fix at the cause. A disable needs a documented reason and is a last resort. - The API is oRPC. Typed procedures in
lib/orpc/router.ts, shared i/o schemas inlib/orpc/schemas.ts, browser client + TanStack Query inlib/orpc/client.ts, one handler atapp/rpc/[[...rest]]/route.ts. Add a procedure there, not a newapp/api/*route. (The only plain REST route left is/api/pdf, binary.) - "AI at the edge, deterministic core." LLM calls do fuzzy intent (structured
output); deterministic code does structure. The chat-edit is a hand-written
bounded loop (
lib/workflow-edit-agent.ts) over the Claude SDK, NOT a Mastra Agent (see its header comment for why). Mastra owns the P2P pipeline (src/mastra/workflows/p2p.ts) and the exception-investigator agent. - Bounded persistence, not stateless. The run writes ONE thing: an append-only
agent_runsaudit row at the end (db/runs.ts), read back by the Recent runs panel + replay. It NEVER writes the document tables or the ERP/HRIS, so a run can't change a future run's verdict (the matcher always reads the pristine seed). A nightly Vercel Cron (/api/reset,vercel.json) truncates+reseeds Postgres so the demo stays pristine; the reset touches Postgres only, never the sandboxes. HITL resume stays REPLAY-based (recompute the deterministic prefix from the decisions), NOT Mastra snapshot/suspend, keep it that way (the snapshot path has a known Postgres-bloat footgun). Don't reintroduce writes to the document tables. - The recorded HRIS fixture is SEED-BUILT, not a live capture
(
scripts/build-recorded-fixture.ts→pnpm fixture:build). recorded == live == the 13-person "LedgerLoop Demo" org. Don't reintroduce a real-capture claim. - Writing style (comments/commits/PRs): plain, direct, no em-dashes, no AI-isms. Match the existing code's comment density and idiom.
- Do NOT touch the GitHub profile repo. Repo README is fine to edit.
pnpm typecheck && pnpm lint && pnpm knip && pnpm test && pnpm format:check && pnpm build
pnpm test= node:test, all faked (free, no API).- Evals:
pnpm eval:edit --dry-runandpnpm eval:edit-agent --dry-runare free (stubs). Live evals cost Anthropic tokens, only run a live eval to PROVE a model change works, never casually. The user's rule: don't waste tokens. - Screenshots: the dev server hits live BambooHR if the key is set (~12s). For fast,
deterministic screenshots run dev with
BAMBOO_HR_API_KEY= BAMBOO_HR_SUBDOMAIN=so it uses the recorded fixture.
- Work on your branch (this worktree is already on it). Commit when the user asks or
when a verified unit of work is done; end commit messages with the
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>trailer. - Don't merge to main yourself unless asked. The user reviews.
- Throwaway scripts/sandbox pages: put them in-repo only while iterating, DELETE before committing (they trip the type-aware linter and knip).
lib/approval-workflow.ts, the workflow DAG model, conditions,humanizeCondition,diffWorkflows.lib/approval-engine.ts, executes the DAG (AND-join, skipped = pass-through).lib/workflow-validate.ts, structural + AP-best-practice checks (the validator).lib/workflow-edit.ts/-agent.ts/-model.ts, the chat-edit ops, the bounded agent loop, the Claude planner.lib/onboarding.ts/-model.ts, derive the workflow from an org.lib/hris.ts, BambooHR adapter (live + recorded) + the mapper.lib/erp.ts, two seams: the reconciliation POST stub (fake-netsuite) AND the PO PULL (QuickBooks live + recorded fixture, same shape as HRIS).defaultErp()picks live/recorded by env;loadRunBundlematches invoices against pulled POs.src/mastra/, the P2P pipeline + investigator agent + run-stream generator.components/, onboarding, workflow-editor, workflow-graph (React Flow), dashboard, trace-timeline.
Ask the user, the link-workflow / department / live-graph work (see "Current state"
above) is done and merged. There may be a TASK.md in this folder from a past branch;
treat it as scaffolding, not a live instruction, unless the user points you at it.