Skip to content

Part 7 of 7 · Quote comparer series ~7 min read

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

The quote comparer drawn with AWS service namesThree boxes across the top outside the AWS account. Quote documents arriving by email and upload. The requested scope, entered once per job. And The comparison page, read by a person. Inside the account, three groups. S3 holding originals with Textract producing text and layout. Three Lambda functions named read, align and render. And two DynamoDB tables named quotes and jobs. A note gives the region as us-east-1, one account, and states that originals are kept immutable and extractions are cached by document hash.AWS ACCOUNTQuote documentsemail and uploadThe requested scopeentered once per jobThe comparison pageread by a personS3 + Textractoriginals,text and layoutLambda x3read, align, renderDynamoDB x2quotes, jobsingroundsoutus-east-1. One account. Originals kept immutable; extractions cached by document hash.
Fig 1. The same shape as Part 1 with the service names filled in. Nothing here is new; it is the same three groups, named.
  • 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 dev and a prod stack 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

FunctionTriggerDoesTimeout / memory
qc-readS3 put on the originals bucketTextract, then one model call per document to structure lines and terms300s / 2048 MB
qc-alignDynamoDB stream on quotesAligns each quote to the job scope; classifies every cell as included, excluded or unclear60s / 1024 MB
qc-renderAPI, on requestBuilds the comparison page and the per-supplier questions30s / 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

RoleAllowedOn
qc-read-roles3:GetObject, textract:*Document*, bedrock:InvokeModel, dynamodb:PutItemThe originals bucket; one model id; the quotes table
qc-align-roledynamodb:Query, dynamodb:UpdateItemQuotes and jobs
qc-render-roledynamodb:Query, s3:GetObjectRead-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