Skip to content

Part 7 of 7 · Fuel log auditor series ~7 min read

Engineering reference: the fuel log auditor 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 check order, and how explanations are stored.

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 fuel log auditor drawn with AWS service namesThree boxes across the top outside the AWS account. The fuel card feed, arriving as a daily file. Vehicle records, holding tank sizes and assignments. And Drivers, who receive one weekly message. Inside the account, three groups. S3 receiving the feed drop alongside EventBridge running a weekly query batch. Three Lambda functions named match, check and query. And two DynamoDB tables named fills and baselines. A note gives the region as us-east-1, one account, and states that raw transactions are immutable and that explanations suppress repeat queries.AWS ACCOUNTFuel card feeddaily fileVehicle recordstanks, assignmentsDriversone weekly messageS3 + EventBridgefeed drop,weekly query batchLambda x3match, check, queryDynamoDB x2fills, baselinesingroundsoutus-east-1. One account. Raw transactions immutable; explanations suppress repeat queries.
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
  • People

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
fl-matchS3 put on the feed prefixAttributes each transaction to a vehicle; validates the odometer against the previous reading120s / 1024 MB
fl-checkDynamoDB stream on fillsRuns impossibility checks, then baseline deviation; looks for a swapped pair60s / 1024 MB
fl-queryEventBridge, weeklyGroups open anomalies by driver, suppresses explained patterns, sends one message60s / 512 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
fl-match-roles3:GetObject, dynamodb:PutItem, dynamodb:QueryThe feed prefix; fills; read-only on vehicles
fl-check-roledynamodb:Query, dynamodb:UpdateItemFills and baselines
fl-query-roledynamodb:Query, dynamodb:UpdateItem, ses:SendEmailFills; one verified identity

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: fills

PK   vehicle_id        S   or ’unattributed’
SK   filled_at         S
     raw               M   the transaction exactly as received, never edited
     card_id           S
     litres            N
     cost_pence        N
     odometer          N   null if not given
     odo_status        S   ok | backwards | implausible | absent | repeated
     miles_since       N   null unless the previous odometer is usable
     mpg               N   null on any of the above
     anomaly           S   over_tank | odo | economy | swap_suspected
     explanation       S   the driver’s answer, verbatim
     explained_pattern S   ’mower can, monthly’ — suppresses future queries

`raw` is kept because every downstream number is derived and the feed
format changes without notice.

Table: baselines

PK   vehicle_id        S
     n_fills           N   fewer than 10 means no alerts at all
     median_mpg        N
     spread_mpg        N   a wide normal needs a wider band
     by_quarter        M   {2025Q4: 31.2, ...} for seasonal comparison
     fleet_delta       N   this quarter’s fleet-wide movement, the control
     updated_at        S

The only cross-vehicle number here is `fleet_delta`, and it is a change
rather than a level. Levels compare routes; changes compare vehicles.

Inbound and outbound

  • The card feed arrives daily as a file into S3. Receipts photographed by drivers are a secondary path and are the only place a model would be involved.
  • Card-to-vehicle assignment is versioned with dates, so a reassignment does not retroactively rewrite three months of attribution.
  • Impossibility checks run before deviation checks. An over-tank fill needs no baseline and can be queried immediately.
  • Explanations suppress future queries on the same pattern. Without this the reply rate collapses within two months.

The model call

  • There is usually no model in this system. Every check is arithmetic against a previous reading or a median.
  • The one defensible use is reading a photographed receipt where no card feed exists, extracting litres, cost, date and site.
  • The wrong use is scoring drivers or transactions for suspicion. Part 4 is about why a system that can express suspicion will be used to.
  • Classifying free-text explanations into patterns is defensible, and the verbatim text is kept because the specifics are what suppress the next query correctly.
  • The cost page assumes none, which is why the bill is almost entirely fixed.

Things worth knowing before you build it

  • Version the card-to-vehicle mapping with dates. A reassignment applied retroactively rewrites months of attribution and destroys every baseline.
  • Never compute miles per gallon when the odometer is absent or implausible. A null is correct; an interpolated figure quietly poisons the baseline.
  • Store explanations against a pattern, not just against the one record. Asking the same question every month is how the reply rate reaches zero.
  • Check for a swapped pair before querying a bad economy figure. Two vehicles anomalous in opposite directions in one week is one error, not two.
  • Do not build a driver ranking, a suspicion score, or a case workflow. Features that can express an accusation will be used to make one.

That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.

All posts