Skip to Content
APISHIP Finding Contract

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.

MethodRouteTransition
GET/api/ship/findingsList with filters + status counts
POST/api/ship/findings/:findingId/disputeOPEN → DISPUTED
POST/api/ship/findings/:findingId/dismissOPEN → DISMISSED
POST/api/ship/findings/:findingId/submitDISPUTED → SUBMITTED
POST/api/ship/findings/:findingId/carrier-reviewSUBMITTED → 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/batchApply 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} ─▶ OPEN

State definitions:

  • OPEN — initial state when the SHIP audit worker surfaces a finding.
  • DISPUTED — customer has disputed the charge. disputedAt is 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. resolvedAt is 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=credit on the batch endpoint returns 400 INVALID_REQUEST).
  • REJECTED — terminal-resolved. resolvedAt is 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)

FieldTypeDescription
actionabilityDISPUTE_READY | REVIEW_REQUIRED | BLOCKEDWhether the finding is ready for dispute, needs manual review, or is blocked on missing data.
headlinestringShort operator-facing summary of the finding (e.g., “Billed $55.50, expected $50.00 — $5.50 overcharge”).
allowedActionsstring[]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'].
claimEligibilityELIGIBLE | INELIGIBLEWhether the finding can be bundled into a new claim submission.
claimBlockerReasonstring | nullIf 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)

FieldTypeDescription
whyItMattersstringOne-sentence explanation of the business impact.
recommendedActionstringShort next-step guidance for the operator.
caveatsstring[]Confidence/risk notes (e.g., “Dimensions were unavailable, so the comparison used actual weight only.”). Empty when no caveats apply.
evidenceFactsobjectNormalized 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:

FieldTypeDescription
explanationExactnessEXACT_COMPONENT | TOTAL_ONLY | ASSUMPTION_BEARING | UNAVAILABLE | nullClassification 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).
discrepancyExplanationobject | nullComponent-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

LevelMeaning
EXACT_COMPONENTFull 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_BEARINGComponent breakdown is available but includes estimated values, unattributed billed charges, or a missing upload-scoped billed snapshot.
TOTAL_ONLYOnly the total expected amount is available; component breakdown is not possible.
UNAVAILABLENo rate-engine calculation exists for this shipment. discrepancyExplanation is null.

discrepancyExplanation object

FieldTypeDescription
exactnessstringSame as explanationExactness.
componentsarrayPer-component comparison: { component, billedAmount, expectedAmount, delta }. Empty array for TOTAL_ONLY.
primaryDriverstring | nullComponent with the largest absolute delta (transportation, fuel, or accessorials). Null when no billed rows or all deltas are zero.
billedBreakdownobject{ transportation, fuel, accessorials, other } — billed amounts grouped by charge type.
expectedMetadataobject | null{ zone, billableWeight, method, fuelPercent, fuelEstimated, contractVersionId } — rate-engine calculation metadata.
caveatsstring[]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)

FieldTypeDescription
primaryActionabilityDISPUTE_READY | REVIEW_REQUIRED | BLOCKED | nullActionability of the highest-ranked active finding on the shipment. Null if no active findings.
primaryHeadlinestring | nullHeadline of the primary finding.
pricingBlockerSummarystring | nullIf the shipment’s latest rate-engine calculation had unavailable reasons, a human-readable summary (e.g., “No contract version found for UPS”).
actionabilityCountsobject{ disputeReady: number, reviewRequired: number, blocked: number } — counts of active findings by actionability bucket.

Allowed-actions matrix

Workflow statusDISPUTE_READYREVIEW_REQUIREDBLOCKED
OPENdispute, dismissdismissdismiss
DISPUTEDsubmit, credit, reject
SUBMITTEDcarrier-review, credit, reject, reopen
CARRIER_REVIEWcredit, reject, reopen
DISMISSED / CREDITED / REJECTEDreopenreopenreopen

Mutation handlers enforce this matrix: attempting an action not in allowedActions returns 409 ACTION_NOT_ALLOWED.

Claim eligibility matrix

Workflow statusEligibilityBlocker reason
OPENINELIGIBLEmust_dispute_first
DISPUTED / SUBMITTED / CARRIER_REVIEWELIGIBLE
DISMISSED / CREDITED / REJECTEDINELIGIBLEworkflow_resolved
Carrier-blocked findingINELIGIBLEcarrier_blocked
Already linked to an active submissionINELIGIBLEalready_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 amount and a confirmation object with source, referenceId, and confirmedAt.
  • Optional fields on confirmation: notes, artifactUrl.
  • Batch credit is disabled: POST /api/ship/findings/batch with action=credit returns 400 INVALID_REQUEST with 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 statusMeaning
okTransition applied. The new workflowStatus is included.
skippedAlready in the target state; no-op.
invalid_transitionFinding is not in an allowed source state for the action.
not_foundFinding 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:

CodeHTTPWhen
INVALID_REQUEST400Missing/invalid body (e.g. credit without confirmation), or action=credit on batch
INVALID_TOKEN401JWT expired or revoked — re-exchange
NOT_FOUND404Finding does not exist or belongs to a different tenant
ACTION_NOT_ALLOWED409Source state is not allowed for the requested action
RATE_LIMITED429Write bucket or tenant-scoped limit
INTERNAL_ERROR500Unexpected 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 ok or skipped.
  • Retry on 429 using details.retryAfterSeconds; do not retry on ACTION_NOT_ALLOWED without 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