REST interface of the core procurement system + the SupplierQuoteObject schema. Prolo's pipeline is a client: submit/update pushes across the boundary (Stage 6), search is for consumers.
Conventions: /v1 · tenant-scoped (tenantId) · idempotent on
messageId · JWT Bearer · money as numeric (enables Σ = net).
Identity: quoteId = a version; quoteGroupId = the logical quote
(a revision is a new version, never an in-place edit).
/docs (from openapi.yaml). Click a row to expand.| Name | In | Type | Notes | |
|---|---|---|---|---|
tenantId | query | string | required | tenancy (RLS) |
vendor, quoteNumber | query | string | filters | |
status | query | QuoteStatus | enum | |
isLatest | query | boolean | current version only | |
page, pageSize | query | integer | default 1 / 50 (≤100) | |
expand | query | string[] | e.g. lineItems,provenance |
| Code | Schema | Description |
|---|---|---|
| 200 | QuoteSearchResponse | page of metadata (no line items) |
| 401/403 | ProblemDetails | auth / wrong tenant |
# 200 · QuoteSearchResponse
{ items: [ QuoteMetadata{ quoteId, quoteGroupId, revision, isLatest,
vendorIdentifier, quoteNumber, quoteDate, currency,
totals{tax,shipping,net}, status, confidence } ],
page, pageSize }
| Name | In | Type | |
|---|---|---|---|
quoteId | path | uuid | required |
expand | query | string[] |
| Code | Schema | Description |
|---|---|---|
| 200 | QuoteResource | metadata + body (+ expands) |
| 404 | ProblemDetails | unknown quote |
# 200 · QuoteResource = QuoteMetadata +
{ …, quote: SupplierQuote{ vendorIdentifier, totals, lineItems[…] },
provenance: [ { field, source, page, bbox } ] }
quoteId | path | uuid | required |
| 200 | QuoteResource[] | all versions, ordered by revision |
Idempotency-Key | header | string | required | = messageId |
SubmitQuoteRequest {
tenantId: string, messageId: string,
quote: SupplierQuote {
vendorIdentifier: string,
totals: { tax, shipping, net : number },
lineItems: [ { sku?: string, description: string,
quantity: number, unitOfMeasure: string, price: number } ] # ≥1
} }
| Code | Schema | Description |
|---|---|---|
| 201 | QuoteWriteResponse | created (+ Location) |
| 200 | QuoteWriteResponse | idempotent replay — existing quote, no double-write |
| 422 | ProblemDetails | schema invalid or Σ(qty×price)+tax+shipping ≠ net |
# 201 · QuoteWriteResponse
{ quoteId, quoteGroupId, revision: 1, status: "EXTRACTED" }
quoteGroupId | path | uuid | required | |
Idempotency-Key | header | string | required | the NEW email's messageId |
SubmitQuoteRequest # same shape as submit
| Code | Schema | Description |
|---|---|---|
| 201 | RevisionWriteResponse | new version; prior → SUPERSEDED (one txn) |
| 409 | ProblemDetails | pure duplicate, not a revision |
# 201 · RevisionWriteResponse
{ quoteId, quoteGroupId, revision: 3, supersedesId, status: "EXTRACTED" }
quoteId | path | uuid | required |
QuoteStatusPatch { status: QuoteStatus } # status-only, not content
| 200 | QuoteWriteResponse | updated |
| 404 | ProblemDetails | unknown quote |
The transmitted contract: vendor identifier · totals (tax/shipping/net) · line-items array — mapped 1:1 to the brief.
{
"title": "SupplierQuoteObject", "type": "object",
"required": ["vendorIdentifier","totals","lineItems"],
"properties": {
"vendorIdentifier": { "type":"string" },
"totals": { "required":["tax","shipping","net"],
"properties": {
"tax": {"type":"number","minimum":0},
"shipping": {"type":"number","minimum":0},
"net": {"type":"number","minimum":0} } },
"lineItems": { "type":"array", "minItems":1,
"items": { "required":["description","quantity",
"unitOfMeasure","price"],
"properties": {
"sku": {"type":["string","null"]},
"description": {"type":"string"},
"quantity": {"type":"number","exclusiveMinimum":0},
"unitOfMeasure": {"type":"string"},
"price": {"type":"number","minimum":0} } } } }
}
| Brief field | Schema path |
|---|---|
| Vendor identifier | vendorIdentifier |
| Totals — Tax/Shipping/Net | totals.tax/.shipping/.net |
| Line Items — SKU/Desc/Qty/UoM/Price | lineItems[].sku/.description/.quantity/.unitOfMeasure/.price |
sku nullable — in the brief's list, but quotes often omit it and
matching is by semantic embedding of description, not SKU.
Example
{ "vendorIdentifier":"Acme Steel Co.",
"totals":{"tax":56,"shipping":40,"net":796},
"lineItems":[{ "sku":"RB-12", "description":"Rebar 12mm",
"quantity":200, "unitOfMeasure":"m", "price":3.50 }] }
200×3.50 = 700 +56 +40 = 796 = net ✅
Appendix — depth on demand; talk to these only if asked (Pydantic types, deployment, error handling). Not part of the §4 slide.
Each endpoint's IN/OUT as Pydantic models — validation layer + the OpenAPI (/docs) contract.
class LineItem(BaseModel):
sku: str | None = None
description: str
quantity: Decimal = Field(gt=0)
unitOfMeasure: str
price: Decimal = Field(ge=0)
class Totals(BaseModel): tax: Decimal; shipping: Decimal; net: Decimal
class SupplierQuote(BaseModel): # == SupplierQuoteObject
model_config = ConfigDict(extra="forbid") # unknown field → 422
vendorIdentifier: str; totals: Totals
lineItems: list[LineItem] = Field(min_length=1)
class QuoteStatus(str, Enum): EXTRACTED; NEEDS_REVIEW; MATCHED; SUBMITTED; SUPERSEDED; FAILED
class ProblemDetails(BaseModel): # RFC-9457 — OUT for every 4xx/5xx
type: str; title: str; status: int; detail: str | None; errors: list[dict] | None
1 · GET /v1/quotes
# IN QuoteSearchParams (query):
# tenantId, vendor?, quoteNumber?, status?,
# isLatest?, page, pageSize(≤100), expand[]
# OUT 200 QuoteSearchResponse:
# items: list[QuoteMetadata]; page; pageSize
2 · GET /v1/quotes/{quoteId}
# IN path quoteId · query expand[]
# OUT 200 QuoteResource = QuoteMetadata
# + quote: SupplierQuote + provenance[]
3 · GET …/{quoteId}/revisions
# IN path quoteId
# OUT 200 list[QuoteResource]
4 · POST /v1/quotes
# IN SubmitQuoteRequest:
# tenantId, messageId, quote: SupplierQuote
# OUT 201/200 QuoteWriteResponse:
# quoteId, quoteGroupId, revision, status
5 · POST …/{quoteGroupId}/revisions
# IN path quoteGroupId + SubmitQuoteRequest
# OUT 201 RevisionWriteResponse:
# …WriteResponse + supersedesId
6 · PATCH /v1/quotes/{quoteId}
# IN QuoteStatusPatch: status
# OUT 200 QuoteWriteResponse
extra="forbid" + Decimal/Field constraints → 422 at the boundary; the Σ = net check runs after Pydantic (business rule).
FastAPI-on-Lambda (Mangum / Web Adapter), behind an internal ALB
(target_type="lambda", no API Gateway), direct to Aurora
PostgreSQL (no RDS Proxy). No public ingress — the AXO373
lambda_fastapi_service shape.
flowchart TB
subgraph CLIENTS["Clients — VPC only"]
PIPE["Prolo pipeline
Stage 6 submitter"]
UI["Procurement UI (reads)"]
end
R53["Route 53 → ALB"]
subgraph VPC["🔒 AWS VPC — private (no public ingress)"]
direction TB
subgraph EDGE["Ingress — ALB, not API Gateway"]
WAF["WAFv2"]
ALB["Internal ALB · HTTPS:443 · ACM
host-header → lambda target group"]
end
subgraph PRIV["Private subnets"]
LAMBDA["λ Lambda — FastAPI
JWT verify (app)"]
AUR[("Aurora PostgreSQL
asyncpg direct, no RDS Proxy")]
end
subgraph SUP["Platform"]
SECR["Secrets Manager / SSM"]
CW["CloudWatch"]
MIG["Alembic — one-shot"]
end
end
PIPE & UI --> R53 --> WAF --> ALB
ALB -->|host-header rule| LAMBDA
LAMBDA ==>|"reserved concurrency bounds pool"| AUR
MIG -.->|migrate| AUR
SECR -.-> LAMBDA
LAMBDA -.-> CW
classDef db fill:#fde047,stroke:#ca8a04,color:#000
class AUR db
style CLIENTS fill:#f2f0ff,stroke:#8250df,color:#1f2328
style VPC fill:#eef4ff,stroke:#2f6fd6,color:#1f2328
style EDGE fill:#e6fbf0,stroke:#1a7f37,color:#1f2328
style PRIV fill:#e2ecff,stroke:#3b7ddd,color:#1f2328
style SUP fill:#fff4e6,stroke:#e8992e,color:#1f2328
execs × pool ≤ max_connections).UNIQUE(tenant,messageId)); revisions in one transaction. Migrations out of the request path.POST is the P2 projection across the boundary.| Situation | Status | Behaviour |
|---|---|---|
| Schema / missing field | 422 | problem+json; not persisted |
Math fails (Σ ≠ net) | 422 | rejected — deterministic guard |
| Duplicate submit (same key) | 200 | existing quote, no double-write |
| Duplicate content, not revision | 409 | conflict |
| Unknown id | 404 | — |
| Missing/invalid JWT | 401 | rejected at auth dependency |
| Valid token, wrong tenant | 403 | sanitised message |
| Core API down / 5xx | 503 | transient → SQS redelivers → DLQ; idempotent retry |
Internal envelope — the pipeline wraps the thin SupplierQuoteObject with trust/audit metadata (not part of the transmitted contract):
{ "quote": { /* SupplierQuoteObject — the only part the core validates */ },
"status":"EXTRACTED|…|SUBMITTED", "confidence":{…}, // drives Human Review
"provenance":[{ "field":"net","page":1,"bbox":[…] }] } // no RFQ data on the quote
§4 in one line: three endpoints — GET (search), POST /quotes
(submit), POST /quotes/{group}/revisions (revision-as-new-version) — over a thin
SupplierQuoteObject validated by schema and the Σ = net check.