Engineering reference: the quote comparer architecture
The first six posts are for the person deciding whether to build this. This one is for the person building it. Same system, no analogies: the services by name, the functions, the two tables, the provenance record, and where the model is and is not used.
Key takeaways
- Single region, single account. Every resource is regional; nothing is global except the IAM roles.
- 3 Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
- 2 DynamoDB tables, each keyed so the concurrency story is a condition expression rather than a lock.
- One Bedrock model, called once, with a JSON schema it must fill or leave null.
- Nothing always-on: no instance, no container, no provisioned capacity.
The system, by service name
- Compute
- Database
- Front-end & mobile
Region and account
- Region:
us-east-1. Chosen because SES inbound receipt rules exist in only a subset of regions and this one has the widest Bedrock model availability. If your data has to stay elsewhere, check both constraints before moving: inbound SES is the binding one. - Account: one. This is a small system, and a separate account per environment costs more in wiring than it saves. A
devand aprodstack in the same account, with distinct resource prefixes, is the right size here. - Everything is regional. The only global resources are the IAM roles and policies. There is no CloudFront, no global table and no cross-region replication, because nothing here has a latency or durability requirement that would justify them.
Lambda inventory
| Function | Trigger | Does | Timeout / memory |
|---|---|---|---|
qc-read | S3 put on the originals bucket | Textract, then one model call per document to structure lines and terms | 300s / 2048 MB |
qc-align | DynamoDB stream on quotes | Aligns each quote to the job scope; classifies every cell as included, excluded or unclear | 60s / 1024 MB |
qc-render | API, on request | Builds the comparison page and the per-supplier questions | 30s / 1024 MB |
Splitting this into separate functions is not about modularity. It is that only one of them needs Bedrock permissions and only one is reachable from the public internet, and neither of those is true if it is one handler behind a router.
IAM, scoped
| Role | Allowed | On |
|---|---|---|
qc-read-role | s3:GetObject, textract:*Document*, bedrock:InvokeModel, dynamodb:PutItem | The originals bucket; one model id; the quotes table |
qc-align-role | dynamodb:Query, dynamodb:UpdateItem | Quotes and jobs |
qc-render-role | dynamodb:Query, s3:GetObject | Read-only; presigned links back to the original pages |
No role has a Resource: “*” on anything that writes, and every GetSecretValue grant names a single secret arn. That is why there is more than one secret rather than one JSON blob with everything in it.
DynamoDB schemas
Table: quotes
PK job_id S bathroom_ashford_2026_08
SK quote_id S supplier#received_at
supplier S as it appears on the document
doc_key S s3 key of the original, immutable
doc_hash S sha256; the extraction cache key
ocr_derived BOOL true if there was no text layer
lines L [{text, qty, unit, amount, page, line}]
terms L [{sentence, page}] — verbatim, unsummarised
provisional N total of provisional sums
firm N total excluding provisional sums
valid_until S from the document, if stated
status S comparable | different_scope | no_detail | expired
`lines` keeps page and line on every entry. That is what makes each
figure on the comparison page a link back to where it came from.
Table: jobs
PK job_id S bathroom_ashford_2026_08
scope L [{item, qty, unit}] — 9 rows, entered once
alignment M {quote_id: {item: included|excluded|unclear}}
evidence M {quote_id: {item: {page, line, text}}}
questions M {quote_id: [question strings]}
`evidence` exists so that ’included’ is never an unsupported claim:
every positive cell names the line that justifies it.
Inbound and outbound
- Documents arrive by email or upload and land in S3 unmodified. Nothing writes back to the originals bucket.
- Extraction is cached by document hash, so re-comparing a job never re-reads a document that has not changed.
- The job scope is entered by a person, once. It is the only place quantities come from, which is why unit prices are never guessed.
- Textract runs first and the model works on its output rather than on raw pixels, which keeps the read cost and the error rate down.
The model call
- One read per document. Structuring lines, units and terms sentences out of Textract output.
- It never compares. Alignment and classification are rules over the structured output, so the same quote produces the same table every time.
- It never estimates a missing quantity. A line with no unit is returned as unclear, and the prompt is explicit that guessing is a failure.
- It never summarises the terms. Terms sentences are extracted verbatim, because the wording is the evidence and a paraphrase is not.
- Provenance is required output. A line item returned without a page and line is rejected and re-requested rather than stored.
Things worth knowing before you build it
- Cache on the document hash, not on the quote id. The same supplier re-sending a corrected PDF must re-read; a second comparison of the same file must not.
- Keep the terms sentences verbatim. The moment they are summarised, the exclusion detection becomes unarguable in exactly the situation where somebody wants to argue about it.
- Separate firm and provisional totals in the data model, not in the template. A single total field will end up quoted somewhere.
- Never delete an unalignable quote from the job. A clean table that omits a supplier misrepresents what was received.
- Label the estimated-gap band as an estimate everywhere it appears, and exclude it from every field named total.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts