# TRD: Coda Agentic Workflow Engine & Contract Creation

Technical design for the agentic-workflow engine in `ows-coda` and its first workflow, contract
creation. Source of truth is the code on `master` of `ows-coda`
(`apps/server/src/ai/workflows/`). For operation see the
[runbook](../../runbooks/coda/contract-creation.md); for the concept see
[Learn](../../learn/coda/contract-creation.md).

## 1. Goals & scope

**Goal.** Let Coda *act*, that is, perform real Abacus writes on a user's behalf, without giving
an LLM authority over the live system. The engine must guarantee that:

1. A human sees the exact change before it happens and explicitly confirms it.
2. What is confirmed is byte-for-byte what executes.
3. Every execution is auditable (who, what, outcome).
4. The mechanism is reusable for the next workflow, not bespoke to contracts.

**In scope:** the workflow state machine, persistence, the HTTP API, the contract-creation
nodes, the extraction eval, and the safety model.

**Out of scope:** the Coda chatbot/agent loop, search, the broader tool catalog, and MCP
exposure. Coda's `apps/server/src/ai/workflows/` is intentionally separate from the chat loop
and reuses only the write *handler* and the cache.

## 2. Design principles

- **One stochastic step.** The LLM appears only in `extract`, and only for document/free-text
  input. Validation, planning, preview and execution are **pure functions of the checkpointed
  draft**. This is what makes "preview equals execution" true and makes everything except
  extraction unit-testable and deterministic.
- **Wrap, don't reimplement.** Execution calls the existing hardened
  `create_distribution_contract` write handler (`ai/tools/contracts-write/handlers.ts`), so the
  QA gate, rate re-validation, fallback-schedule logic, read-back guards and partial-failure
  messages are shared with the chatbot, not forked.
- **Mirror the handler in validation.** The validator deliberately re-encodes the handler's
  business rules so problems surface in the preview; the handler remains the final guard.
- **Additive and flag-gated.** The routes don't exist unless `CODA_WORKFLOWS_ENABLED`; real
  writes need dry-run off *and* `ENVIRONMENT=qa`.

## 3. Architecture

A LangGraph `StateGraph` with six nodes and one conditional edge:

```mermaid
flowchart TD
    START((START)) --> EX[extract]
    EX --> VA[validate]
    VA --> PR[prepare]
    PR --> RV{{review · interrupt}}
    RV -->|confirmed| EXE[execute]
    RV -->|"null = amended"| VA
    RV -->|cancelled| ENDc((END))
    EXE --> AU[audit]
    AU --> ENDa((END))
```

Defined in `contract-creation/graph.ts`; nodes live in sibling files (`extractor.ts`,
`validate.ts`, `prepare.ts`, `execute.ts`) with shared types in `draft.ts`. The graph is
compiled with the `CacheStoreCheckpointSaver` so every superstep is persisted.

### 3.1 State

```ts
WorkflowState = {
  input,        // { text?, document?, params? }
  identityId,   // owner; thread is private to this identity
  draft,        // ContractDraft | null   the one object every node reads
  validation,   // ValidationResult | null
  plan,         // ExecutionPlan | null   tool input + ordered mutations
  preview,      // WorkflowPreview | null  human-facing summary
  decision,     // "confirmed" | "cancelled" | null
  execution,    // ContractExecutionResult | null
  auditId,      // string | null
}
```

Callers never see raw state. `toStatusView()` projects it into a `WorkflowStatusView`
(`status`, `preview`, `issues`, `missingFields`, `execution`, `auditId`) and the route strips
`identityId` from the response.

### 3.2 The draft (`draft.ts`)

`contractDraftSchema` (zod) mirrors the `create_distribution_contract` tool input with **every
field optional**. Extraction fills what the document states; `validateDraft` owns required-ness.
The same schema is the structured-output schema for the extraction model, so its field
descriptions are written *for the model*. `normalizeDraft` canonicalises shapes (IDs to
non-empty strings, trimmed names, dropped empty arrays) so downstream nodes can assume clean
input. `effectiveTermAttachments(draft, term)` resolves a term's labels to its explicit
`attachments`, else the draft-level `default_term_attachments`.

## 4. The nodes

### 4.1 extract: the only LLM call

`BedrockContractExtractor` (`extractor.ts`) uses LangChain's `ChatBedrockConverse` at
**temperature 0** with `.withStructuredOutput(contractDraftSchema)`. Document uploads use the
Converse "standard data block" content shape; filenames are sanitised
(`sanitizeDocumentName`) because Converse rejects dots/underscores in document names.

The system prompt is conservative about the high-risk fields: never invent the numeric IDs
(`account_id`, `signing_entity_id`, `run_controller_id`) or label/vendor attachments; capture
the signing entity's legal name as a *hint* only; model rate variations within a catalogue as
**conditions on one term**, not extra terms; total `term_rate + commission` to 100.

In the graph node, when `params` are supplied they override extracted values
(`{ ...extracted, ...params }`); when `params` are the only input, the model isn't called at all.
`FakeContractExtractor` is the deterministic test/eval seam.

### 4.2 validate: pure gate (`validate.ts`)

Produces `{ issues, missingFields, blocking }`:

- **Required:** `contract_name`, `account_id`, `signing_entity_id`, `run_controller_id`,
  `lifecycle_term_start`; at least one term; each term at least one condition.
- **Rates:** each condition's `term_rate` and `commission` in [0,100], and
  `round((term_rate + commission), 3)` within 0.01 of 100 (same rounding/tolerance as the
  handler).
- **Attachments:** every term must resolve to at least one label/vendor ID; if any term has
  none, the validator pushes `default_term_attachments` as a missing field (memos never state
  these).
- **One label, one term:** a label attached to two terms is an *error* (Abacus rejects
  "Following attachment already exists").
- **Dates:** real `YYYY-MM-DD` (calendar-checked).
- **Excluded territories** absent gives a *warning* (platform default `["RUS"]` applies), not
  blocking.

`blocking = missingFields.length > 0 || any error-severity issue`.

### 4.3 prepare: plan + preview from one draft (`prepare.ts`)

`buildExecutionPlan(draft)` returns `{ steps, toolInput }`:

- `toolInput` is the **exact** input `execute` will pass to the write handler
  (`buildToolInput`), with hint-only fields like `signing_entity_name` excluded.
- `steps` mirror the handler's mutation sequence with its defaults applied
  (`renewalType` fallback, `terminationNoticeDays` 90, `excluded_countries` `["RUS"]`,
  `isBaseTerm` on the first term, condition `priority` by position), with runtime IDs shown as
  placeholders (`(contract-id from step 1)`, `(term-id from previous step)`).

`buildPreview(draft, plan, validation)` returns the human summary plus `plan.steps` plus the
warning messages. **Both the preview and the execution input derive from the same checkpointed
draft**, the central correctness property.

The four planned mutations:

| # | Mutation | Purpose |
|---|----------|---------|
| 1 | `abacusCreateContractWithLifecycles` | Contract + lifecycle schedule(s). A non-`CONTINUOUSLY_ACTIVE` renewal appends a fallback `CONTINUOUSLY_ACTIVE` schedule. |
| 2 | `abacusCreateContractTerm` (per term) | First term is `isBaseTerm`; each term carries its label attachments. |
| 3 | `abacusCreateContractTermConditions` (per term) | Rate conditions, `priority` defaulting to array position. |
| 4 | `abacusUpdateContractExclusions` | Excluded territories. |

### 4.4 review: the human gate

```ts
const resume = interrupt(state.preview ? toStatusView("", state) : null);
```

`review` calls LangGraph `interrupt()`, which **suspends the graph and persists**. The run
returns to the caller as `awaiting_confirmation` (or `needs_input` when blocking). A later HTTP
request resumes with a `ReviewResume` command:

- `confirm`: `decision = "confirmed"`, route to `execute`.
- `cancel`: `decision = "cancelled"`, route to `END`.
- `amend`: parse the patch, `normalizeDraft({ ...draft, ...patch })`, `decision = null`, route
  back to `validate` (re-validate, re-prepare, re-pause). This is the loop that lets a reviewer
  supply the IDs a memo lacked.

`routeAfterReview` switches on `decision`; the default branch throws (no silent fallthrough).
`RECURSION_LIMIT = 100` bounds amend rounds (~4 supersteps each).

### 4.5 execute: wrap the real write (`execute.ts`)

`ContractExecutor` has two implementations:

- `RealContractExecutor` calls the `create_distribution_contract` handler with **the confirming
  request's own identity headers** (`buildHeaders(req)`), never a service token, so
  graphql-abacus enforces that user's permissions. Returns `executed` or, on handler error,
  `failed` (carrying the partial result/error).
- `DryRunContractExecutor` returns `dry_run` with `plannedMutations = plan.steps` and writes
  nothing, so the whole flow is testable offline.

The node is **idempotent**: it returns early if `execution` already exists or `decision` isn't
`confirmed`, and re-checks `validation.blocking` before running. The graph guarantees at most
one real call per thread; `resumeContractWorkflow` additionally short-circuits terminal threads
so a double-confirm returns the original result.

### 4.6 audit: record the attempt (`audit.ts`)

`WorkflowAuditLog.record()` writes one `WorkflowAuditRecord` (auditId `audit_<threadId>`,
identity, confirmed `toolInput`, outcome, error, `dryRun`, timestamp) to the structured log
*and* to `coda:wf:audit:<threadId>` (JSON, **30-day TTL**). Written before the result returns
to the caller; idempotent on `auditId`.

## 5. Persistence: the checkpointer (`checkpointer.ts`)

`CacheStoreCheckpointSaver extends BaseCheckpointSaver`, backed by the app's `CacheStore` (Redis
when `REDIS_URL` is set, in-memory otherwise, the same as the conversation cache but a separate
keyspace). Per thread+namespace under `coda:wf:ckpt:*` (**7-day TTL**):

| Key suffix | Holds |
|------------|-------|
| `cp:<checkpointId>` | serde-serialized checkpoint + metadata (+ parent id) |
| `latest` | id of the most recent checkpoint |
| `index` | ordered checkpoint ids (oldest first) |
| `wr:<checkpointId>` | pending writes keyed `taskId,idx` |

Key segments are JSON-encoded so user-influenced thread IDs can't collide with the key grammar.
Regular writes are idempotent (first write wins, matching `MemorySaver`); special negative-index
writes overwrite. This is what lets a paused `review` survive across requests and processes.

## 6. HTTP API (`routes/workflows.ts`)

Mounted at `/api/v1/workflows` only when `config.workflows.enabled`, inside the standard `/api`
middleware (auth, tenancy, permissions, rate limiting).

| Method | Path | Notes |
|--------|------|-------|
| POST | `/contract-creation` | Start; body = `text` and/or `document` and/or `params` (at least one required). 201 + status view. 502 on extraction failure. |
| GET | `/contract-creation/:threadId` | Owned status view. |
| POST | `/contract-creation/:threadId/amend` | `{ patch }`. 409 if terminal. |
| POST | `/contract-creation/:threadId/confirm` | Execute. Idempotent; 409 `needs_input` if blocking. |
| POST | `/contract-creation/:threadId/cancel` | Abandon. Idempotent. |

- **Ownership:** `loadOwnedView` returns 404 when the thread is unknown *or* belongs to another
  identity, so existence isn't leaked across users. Thread IDs are validated against
  `wf_[a-f0-9-]{36}`.
- **Document limits:** allowed MIME types are PDF / txt / md / csv / doc / docx; `base64` up to
  ~34 MB.
- The React client drives this via the `useContractWorkflow` hook; a static `/workflows-dev`
  console exists in non-prod for manual testing with the dev auth bypass.

## 7. Security model

1. **Flag + environment gating.** Routes absent unless enabled; real writes need
   `DRY_RUN=false` **and** `ENVIRONMENT=qa` (the handler's `requireQA` gate).
2. **Identity forwarding.** Execution uses the confirming user's headers, so permissions are
   enforced by graphql-abacus exactly as in the UI. Coda holds no elevated write token.
3. **Per-thread ownership.** A thread is private to its creating identity.
4. **Human confirm gate.** No mutation without an explicit `confirm` against a non-blocking,
   previewed draft.
5. **Idempotency.** At most one execution per thread; terminal threads can't be re-driven.

## 8. Quality & testing

- **Deterministic pipeline, covered by unit tests.** `validate`, `prepare`/scoring, `execute`,
  `graph`, `extractor` plumbing, and the checkpointer are covered by vitest
  (`contract-creation/__tests__/`, `workflows/__tests__/checkpointer.test.ts`).
- **Stochastic step, covered by an eval harness.** `apps/server/evals/contract-extraction`
  scores extraction against fixtures. `scoring.ts` does weighted field accuracy: IDs and
  required fields weigh 2×, rates compare within ±0.001, arrays as sets, conditions positionally
  after canonical priority ordering, and a **hallucinated identifier** (present in extraction,
  absent from the document) is penalised at the heavy weight, because invented IDs are the worst
  failure mode. `run.ts` also asserts each fixture's expected validation gate. Default pass
  threshold **0.9**; `pnpm eval:contract-extraction` (add `--fake` for an offline self-test).
  A teaching walkthrough of how the harness works and why it raises reliability is in
  [Learn: Coda Evals](../../learn/coda/evals.md).

## 9. Extensibility

The engine is the reusable part; contract creation is one instantiation of the
extract → validate → prepare → review → execute → audit shape. A new workflow supplies its own
draft schema, validator, planner/preview, and an executor that wraps an existing write handler,
and reuses the checkpointer, audit log, interrupt/resume machinery, and route conventions
unchanged. Keeping the engine free of contract-specific assumptions (only the four
`contract-creation/` node files know about contracts) is a maintenance requirement, not just a
nicety.
