SHIP Finding Workflow Contract
Status: Active
Version: v1
Last updated: 2026-04-13
This document is the normative state-machine contract for the SHIP finding workflow. It is the workflow-semantics companion to docs/api/openapi/rgl8r-public-api-v1.2.0.yaml, which documents request/response shapes.
The finding workflow is intentionally documented separately from the claim submission workflow (docs/api/ship-claim-submission-contract-v1.md) because the two have distinct state machines and distinct terminal semantics.
Routes in scope
All routes below are callable with an integration-key Bearer JWT obtained via POST /api/auth/token/integration.
| Method | Route | Transition |
|---|---|---|
| GET | /api/ship/findings | List with filters + status counts |
| POST | /api/ship/findings/:findingId/dispute | OPEN → DISPUTED |
| POST | /api/ship/findings/:findingId/dismiss | OPEN → DISMISSED |
| POST | /api/ship/findings/:findingId/submit | DISPUTED → SUBMITTED |
| POST | /api/ship/findings/:findingId/carrier-review | SUBMITTED → CARRIER_REVIEW |
| POST | /api/ship/findings/:findingId/credit | {DISPUTED, SUBMITTED, CARRIER_REVIEW} → CREDITED (terminal-resolved, billing-critical) |
| POST | /api/ship/findings/:findingId/reject | {DISPUTED, SUBMITTED, CARRIER_REVIEW} → REJECTED (terminal-resolved) |
| POST | /api/ship/findings/:findingId/reopen | {DISMISSED, CREDITED, REJECTED, SUBMITTED, CARRIER_REVIEW} → OPEN |
| POST | /api/ship/findings/batch | Apply one action to many findings in a single call |
The canonical transition table is defined in apps/api/src/lib/ship/transitions.ts. The routes above are thin wrappers that look up the transition and apply it inside a single transaction together with the audit trail.
State machine
┌─────────────────────────────────────────────────────┐
│ │
│ ▼
OPEN ──dispute──▶ DISPUTED ──submit──▶ SUBMITTED ──carrier-review──▶ CARRIER_REVIEW
│ │ │ │
│ ├────credit──────────┼──────────credit───────────────┤──▶ CREDITED (terminal-resolved)
│ │ │ │
│ └────reject──────────┼──────────reject───────────────┤──▶ REJECTED (terminal-resolved)
│ │
└─────dismiss───▶ DISMISSED │
│
reopen from any of {DISMISSED, CREDITED, REJECTED, SUBMITTED, CARRIER_REVIEW} ─▶ OPENState definitions:
OPEN— initial state when the SHIP audit worker surfaces a finding.DISPUTED— customer has disputed the charge.disputedAtis set.SUBMITTED— the dispute has been packaged into a submission and sent to the carrier.CARRIER_REVIEW— the carrier has acknowledged the submission and is reviewing.CREDITED— terminal-resolved.resolvedAtis set. This is the billing-critical state — recording a credit feeds tenant billing calculations. Use the per-finding credit endpoint; batch credit is disabled (action=crediton the batch endpoint returns400 INVALID_REQUEST).REJECTED— terminal-resolved.resolvedAtis set. The carrier declined the dispute.DISMISSED— the customer closed the finding without a dispute.
Terminal-resolved states (CREDITED, REJECTED) set resolvedAt at the moment of transition. Reopening any terminal or in-flight state clears both disputedAt and resolvedAt and returns the finding to OPEN.
Finding explanations and actionability
Every finding response includes server-derived explanation fields that answer three operator questions: what was off, why it matters, and whether it is dispute-ready.
Compact fields (all finding list and detail responses)
| Field | Type | Description |
|---|---|---|
actionability | DISPUTE_READY | REVIEW_REQUIRED | BLOCKED | Whether the finding is ready for dispute, needs manual review, or is blocked on missing data. |
headline | string | Short operator-facing summary of the finding (e.g., “Billed $55.50, expected $50.00 — $5.50 overcharge”). |
allowedActions | string[] | Workflow actions available for this finding given its current state and actionability. An OPEN + REVIEW_REQUIRED finding allows ['dismiss'] only; an OPEN + DISPUTE_READY finding allows ['dispute', 'dismiss']. |
claimEligibility | ELIGIBLE | INELIGIBLE | Whether the finding can be bundled into a new claim submission. |
claimBlockerReason | string | null | If ineligible: must_dispute_first, workflow_resolved, carrier_blocked, or already_in_active_submission. Null when eligible. |
Detail fields (shipment detail and single-finding responses only)
| Field | Type | Description |
|---|---|---|
whyItMatters | string | One-sentence explanation of the business impact. |
recommendedAction | string | Short next-step guidance for the operator. |
caveats | string[] | Confidence/risk notes (e.g., “Dimensions were unavailable, so the comparison used actual weight only.”). Empty when no caveats apply. |
evidenceFacts | object | Normalized key facts for display (e.g., { billedAmount: 55.5, expectedAmount: 50.0, delta: 5.5 }). |
Discrepancy explanation (AMOUNT_VARIANCE detail only)
For AMOUNT_VARIANCE findings in shipment detail responses, two additional fields provide a component-level breakdown of the billed-vs-expected variance:
| Field | Type | Description |
|---|---|---|
explanationExactness | EXACT_COMPONENT | TOTAL_ONLY | ASSUMPTION_BEARING | UNAVAILABLE | null | Classification of how precise the component comparison is within the authoritative persisted ship_upload billed snapshot and latest persisted calculation. Only present on AMOUNT_VARIANCE findings in detail views; omitted entirely for other finding types. Value is UNAVAILABLE when no rate-engine calculation exists (discrepancyExplanation will be null). |
discrepancyExplanation | object | null | Component-level breakdown when available. Only present on AMOUNT_VARIANCE findings in detail views; omitted entirely for other finding types. Null within AMOUNT_VARIANCE scope when exactness is UNAVAILABLE (no rate-engine calculation). |
Exactness levels
| Level | Meaning |
|---|---|
EXACT_COMPONENT | Full component breakdown (transportation, fuel, accessorials) with matched rate-engine data, no caveats, and an authoritative billed snapshot from the latest linked ship_upload job. |
ASSUMPTION_BEARING | Component breakdown is available but includes estimated values, unattributed billed charges, or a missing upload-scoped billed snapshot. |
TOTAL_ONLY | Only the total expected amount is available; component breakdown is not possible. |
UNAVAILABLE | No rate-engine calculation exists for this shipment. discrepancyExplanation is null. |
discrepancyExplanation object
| Field | Type | Description |
|---|---|---|
exactness | string | Same as explanationExactness. |
components | array | Per-component comparison: { component, billedAmount, expectedAmount, delta }. Empty array for TOTAL_ONLY. |
primaryDriver | string | null | Component with the largest absolute delta (transportation, fuel, or accessorials). Null when no billed rows or all deltas are zero. |
billedBreakdown | object | { transportation, fuel, accessorials, other } — billed amounts grouped by charge type. |
expectedMetadata | object | null | { zone, billableWeight, method, fuelPercent, fuelEstimated, contractVersionId } — rate-engine calculation metadata. |
caveats | string[] | Human-readable notes about assumptions or gaps in the comparison. |
These fields are additive: they appear only on AMOUNT_VARIANCE findings in shipment detail responses and are omitted entirely from list endpoints and non-AMOUNT_VARIANCE findings (not present as null — absent from the response object).
If no linked ship_upload snapshot exists for a shipment, SHIP fails closed for discrepancy explanation: it does not fall back to shipment-wide billed rows, and the response downgrades exactness instead of synthesizing a cross-upload billed breakdown.
Shipment-level summary (audit list and shipment detail)
| Field | Type | Description |
|---|---|---|
primaryActionability | DISPUTE_READY | REVIEW_REQUIRED | BLOCKED | null | Actionability of the highest-ranked active finding on the shipment. Null if no active findings. |
primaryHeadline | string | null | Headline of the primary finding. |
pricingBlockerSummary | string | null | If the shipment’s latest rate-engine calculation had unavailable reasons, a human-readable summary (e.g., “No contract version found for UPS”). |
actionabilityCounts | object | { disputeReady: number, reviewRequired: number, blocked: number } — counts of active findings by actionability bucket. |
Allowed-actions matrix
| Workflow status | DISPUTE_READY | REVIEW_REQUIRED | BLOCKED |
|---|---|---|---|
| OPEN | dispute, dismiss | dismiss | dismiss |
| DISPUTED | submit, credit, reject | — | — |
| SUBMITTED | carrier-review, credit, reject, reopen | — | — |
| CARRIER_REVIEW | credit, reject, reopen | — | — |
| DISMISSED / CREDITED / REJECTED | reopen | reopen | reopen |
Mutation handlers enforce this matrix: attempting an action not in allowedActions returns 409 ACTION_NOT_ALLOWED.
Claim eligibility matrix
| Workflow status | Eligibility | Blocker reason |
|---|---|---|
| OPEN | INELIGIBLE | must_dispute_first |
| DISPUTED / SUBMITTED / CARRIER_REVIEW | ELIGIBLE | — |
| DISMISSED / CREDITED / REJECTED | INELIGIBLE | workflow_resolved |
| Carrier-blocked finding | INELIGIBLE | carrier_blocked |
| Already linked to an active submission | INELIGIBLE | already_in_active_submission |
Credit endpoint: billing-critical rules
POST /api/ship/findings/:findingId/credit is the only way to record a credit. It is documented as normative launch behavior in docs/api/public-api-contract-v1.md#ship-credit-confirmation-contract-billing-critical. Key rules:
- Request body must include a positive
amountand aconfirmationobject withsource,referenceId, andconfirmedAt. - Optional fields on
confirmation:notes,artifactUrl. - Batch credit is disabled:
POST /api/ship/findings/batchwithaction=creditreturns400 INVALID_REQUESTwith a remediation message pointing to the per-finding endpoint.
Batch endpoint semantics
POST /api/ship/findings/batch applies one action to a list of findingIds. The response contains one result entry per input finding, with a per-finding status:
| Per-finding status | Meaning |
|---|---|
ok | Transition applied. The new workflowStatus is included. |
skipped | Already in the target state; no-op. |
invalid_transition | Finding is not in an allowed source state for the action. |
not_found | Finding does not exist or does not belong to the authenticated tenant. |
Batch behavior is best-effort: a single invalid entry does not roll back the rest. Callers must inspect each result.
Allowed batch actions: dispute, dismiss, submit, carrier-review, reject, reopen. Credit is excluded as noted above.
Error envelopes
All errors follow the canonical public envelope. Endpoint-specific errors:
| Code | HTTP | When |
|---|---|---|
INVALID_REQUEST | 400 | Missing/invalid body (e.g. credit without confirmation), or action=credit on batch |
INVALID_TOKEN | 401 | JWT expired or revoked — re-exchange |
NOT_FOUND | 404 | Finding does not exist or belongs to a different tenant |
ACTION_NOT_ALLOWED | 409 | Source state is not allowed for the requested action |
RATE_LIMITED | 429 | Write bucket or tenant-scoped limit |
INTERNAL_ERROR | 500 | Unexpected failure; retry with backoff |
Retry guidance
- State-transition writes are not idempotent in a general sense, but transitions into the same terminal state from the same source state are safe to retry — the second call returns
ACTION_NOT_ALLOWED(already in target), which the caller should treat as success. - For batch calls, retry only the entries whose per-finding result was not
okorskipped. - Retry on
429usingdetails.retryAfterSeconds; do not retry onACTION_NOT_ALLOWEDwithout re-reading state.
Cross-references
- OpenAPI artifact:
docs/api/openapi/rgl8r-public-api-v1.2.0.yaml - Public API contract pack:
docs/api/public-api-contract-v1.md - Claim submission contract (downstream of findings):
docs/api/ship-claim-submission-contract-v1.md - Quickstart and agent kit:
docs/developers/quickstart.md,docs/api/agent-integration-kit-v1.md - Sample fixture:
docs/api/examples/fixtures/ship-invoice-sample.csv - Example scripts:
docs/api/examples/rgl8r_ship_quickstart.ts,docs/api/examples/rgl8r_ship_quickstart.py