Skip to content

Document review

A model reads a document and proposes values, and a person checks them before the workflow goes on. The review form shows the document beside the fields, marks each value the model was unsure of, and records what the person changed. The corrections are stored on the row they were made on, so a report can compare one model version with the next.

const review = await conditional<IntakeReviewV1>(signalId, {
role: 'intake-verifier',
description: `Account application ${applicationId}`,
metadata: { applicationId, extractor: 'intake-extractor-v1' },
envelope: { formDefaults: extraction.values, confidence: extraction.confidence },
files: [
{ path: `intake/${applicationId}/application.pdf`, as: 'application' },
{ path: `intake/${applicationId}/id-card.jpg`, as: 'id_card', name: 'ID card' },
],
schemaVersion: 1,
});
The application beside the fields it proposed. The review key counts each tranche and the edits so far.
  1. Store the documents. lt.files.upload writes the bytes and keeps their SHA-256 beside them; name the digest you expect and a mismatch answers 422 with nothing stored.

    await lt.files.upload({ path: `intake/${applicationId}/application.pdf`, buffer: pdfBytes });
  2. Give the role a form with a document field per file, "x-lt-layout": "split", and x-lt-confidence at the root.

    {
    "x-lt-layout": "split",
    "x-lt-confidence": {
    "tranches": [
    { "below": 0.6, "label": "Low", "tone": "error" },
    { "below": 0.85, "label": "Check", "tone": "warning" }
    ],
    "facets": true
    },
    "properties": {
    "application": { "type": "string", "title": "Application", "x-lt-widget": "document", "x-lt-source": "file.application" },
    "first_name": { "type": "string", "title": "First name", "x-lt-bind": "applicant.first_name" },
    "date_of_birth": { "type": "string", "format": "date", "x-lt-bind": "applicant.date_of_birth" }
    }
    }
  3. Park the review with conditional. The proposals go in envelope.formDefaults, the scores in envelope.confidence in the same shape, the files in files, and the model’s name and version in metadata.

A file ref names an object in file storage (S3, GCS through its S3 API, or MinIO locally). The ref rides the row; the bytes stay in storage.

FieldRequiredMeaning
pathyesThe storage key. A leading / is dropped; . and .. segments are refused
asyesThe form address, file.<as>: a letter or _, then letters, digits, _ or -
namenoThe name the viewer shows and downloads under. Defaults to the key’s last segment
contentTypenoThe media type, for keys without an extension. Defaults to the type the extension names
expiresInnoThe signed link’s lifetime in seconds

A row carries up to 32 refs, each as once. conditional folds them into the reserved envelope.files key, beside formDefaults and lookups, so a wait with files costs the same as one without. A malformed ref throws before the row is written. Over POST /api/escalations the same check answers 400, and naming a file takes builder access (superadmin or engineer), because a ref hands every reader of the row a link to that object.

Anyone who may read the row receives time-limited links to exactly the files it names:

Terminal window
GET /api/escalations/:id/files
# { "files": [{ "as": "application", "path": "intake/APP-1042/application.pdf", "name": "application.pdf",
# "contentType": "application/pdf", "url": "https://...", "expiresAt": "2026-11-09T15:39:31.859Z" }] }

A link lives for the ref’s expiresIn, or the default. When the reader holds the live claim, the link stretches to the claim’s end, so a claimant’s documents stay open for the whole claim. A ref that cannot be signed answers with error in its own entry, and the response carries Cache-Control: no-store.

VariableDefaultMeaning
LT_ESCALATION_FILE_URL_TTL_SECONDS900A link’s lifetime when the ref names none
LT_ESCALATION_FILE_URL_MIN_SECONDS60The shortest link signed
LT_ESCALATION_FILE_URL_MAX_SECONDS14400The longest link signed, claim included

The dashboard signs when the page opens and again on claim, and a re-signed link keeps the open document and its place. An agent reads the same answer with the get_escalation_files MCP tool, as itself and under the same read check; SDK callers use lt.escalations.getFiles({ id }).

The document widget renders a file in place. PDFs show as continuous pages, fit to width, with paging, zoom and rotation, and pages paint as they near the view. Images fit the frame with zoom and quarter turns. Any other type shows as an open link. Every viewer has open in a new tab and download.

The field is display-only, so its value stays out of the payload. It stays readable and pageable before the claim and on a resolved row. "x-lt-showIf": "file.application" shows a field only when the row names that file, in the dashboard and in the server’s enforcement pass alike.

"x-lt-layout": "split" puts the form’s documents in a pane beside the fields. The pane stays in view while the fields scroll; one document fills it, and two or more scroll within it. Below the split width the pane stacks above the fields. See Widgets and Layout.

x-lt-confidence reads the proposals and the scores in payload shape and maps them through x-lt-bind, so a field bound to applicant.first_name takes its proposal and its score from that path. A score falls into the first tranche whose below it is under; a score at or above every threshold is confident and shows plainly. "x-lt-confidence": true uses the defaults, Low under 0.6 and Check under 0.85, and a field opts out with "x-lt-confidence": false. The full token is in the x-lt-confidence reference.

What the reviewer sees:

  • The tranche on the field. The control’s rule takes the tranche tone, and a line under it names the tranche and the score: Low · 52%.
  • The review key above the fields. One entry per tranche with its count, and Edited with the changed answers out of the proposed ones. Press Low and the next low-confidence field scrolls into view and takes focus.
  • What it was, while editing. A changed answer shows Edited. Press it to see the proposal and Restore it.
  • What it became, after submit. A resolved form shows each changed field as Was Jon → John, beside its tranche.
After submit, each corrected field shows what the model proposed and what the reviewer sent.

The proposal comes from the envelope, which the resolve leaves untouched, and the answer from the stored resolver payload, so a resolved row shows the same review for as long as it exists. Answers compare as values: an empty string equals a missing answer, "42" equals 42, text compares by its words, and object key order is ignored. The payload is stored exactly as submitted.

With "facets": true, the server summarizes each review when the row resolves and merges the summary into its metadata, inside the same guarded update that resolves it:

KeyValue
review_outcomecorrected when any shown answer differs from its proposal, else accepted
review_changedHow many shown answers differ, as a string ("3")
review_worstThe lowest tranche any shown field fell into ("Low"); absent when every score cleared

The server computes them from the role’s schema, so an answer from the form, the API or an MCP tool reports the same way, and they win over caller metadata of the same name. Values are strings, so metadata @> filters and analytics groupings match them exactly.

The facets need enforce_schema: true on the role, since that is when the server holds the form an answer is checked against. Every single-row resolve stamps them. A bulk resolve applies one payload to many rows and leaves review_outcome off, which keeps bulk approvals apart from reviewed ones. review_worst names the tranche from the row’s pinned schema version.

A resolved row keeps what was proposed (envelope.formDefaults), how sure the model was (envelope.confidence), what the reviewer sent (resolver_payload), who resolved it (metadata.resolved_by), the work time (resolved_at - claimed_at) and the schema version that set the tranches.

Trends come from the review facets and the resolved measure: rows resolved in a window, per day, week or month, grouped by any facets. It runs over HTTP, the SDK, the CLI and the aggregate_by_facets MCP tool, so an agent can answer “compare the extractor versions’ correction rates by week since October” in one call:

await lt.escalations.aggregateByFacets({
query: { role: 'intake-verifier' },
groupBy: { facets: ['extractor', 'review_outcome'] },
measure: { kind: 'resolved', window: { from: '2026-10-01T00:00:00Z', to: '2026-11-01T00:00:00Z' }, bucket: 'week' },
});
// [{ bucket: '2026-09-28T00:00:00.000Z', facets: { extractor: 'intake-extractor-v1', review_outcome: 'corrected' }, count: 412 }, ...]

Group by review_worst to see how often each tranche led to a correction, or by review_changed for a histogram of edits per review. search_by_facets with { "review_outcome": "corrected" } lists the rows behind a count.

Per-field detail (which fields are corrected most, and how often each score band was right) reads the row columns directly. Run it on a read replica or a warehouse copy, since it compares every scored field of every row in the window. It walks the scores, so a field with a proposal and no score is left out, and it compares stored JSON strictly:

WITH RECURSIVE scored AS (
SELECT e.id, ARRAY[k] AS path, v
FROM lt_escalations e, jsonb_each(e.envelope->'confidence') AS t(k, v)
WHERE e.role = 'intake-verifier' AND e.status = 'resolved'
AND e.resolved_at >= '2026-10-01' AND e.resolved_at < '2026-11-01'
UNION ALL
SELECT s.id, s.path || t.k, t.v
FROM scored s, jsonb_each(s.v) AS t(k, v)
WHERE jsonb_typeof(s.v) = 'object'
)
SELECT array_to_string(s.path, '.') AS field,
width_bucket((s.v)::numeric, 0, 1, 10) AS score_band,
count(*) AS reviewed,
count(*) FILTER (
WHERE (e.envelope->'formDefaults' #> s.path) IS DISTINCT FROM (e.resolver_payload #> s.path)
) AS corrected
FROM scored s JOIN lt_escalations e ON e.id = s.id
WHERE jsonb_typeof(s.v) = 'number'
GROUP BY 1, 2
ORDER BY 1, 2;

The same comparison is exported for workflow and agent code, so a workflow can record what a person corrected: the feedback a model’s next round learns from.

import { differsFromProposal } from '@hotmeshio/long-tail';
const corrections = Object.entries(flat(proposed))
.filter(([path, value]) => differsFromProposal(value, get(review, path)));

readConfidenceSpec, buildFormReview, summarizeReview and buildReviewFacets are exported beside it.

The long-tail repository’s examples/workflows/document-intake is the reference: a split form with the application and ID card in the pane, nine scored proposals in two tranches, review facets and the extractor version on the row, and an activity that records each correction. Invoke documentIntake from the Invoke Tool with { "data": { "applicationId": "APP-1043" } }, then open the row in the intake-verifier queue.