Skip to content

Part 7 of 7 · Packaging data reporter series ~7 min read

Engineering reference: the packaging data reporter architecture

The same system with the service names filled in: what reads a specification, what multiplies units by grams, what decides the household split, and where seven years of evidence sits.

Key takeaways

  • Single region, single account. Every resource is regional; nothing is global except the IAM roles.
  • 5 Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
  • 3 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 packaging data reporter drawn with AWS service namesThree boxes across the top sit outside the AWS account. Range data covering product lines and their components. Specifications as supplier sheets, drawings and emails. And Sales exports carrying units and customers, monthly. Each connects to the AWS account container below. Inside are three components. S3 with EventBridge handling imports and the period close. Five Lambda functions covering spec, oblige, tonnage, split and submit. And three DynamoDB tables holding components, weights and periods. A note says eu-west-2, one account, specifications are read as they arrive, sales land monthly, and the period close freezes everything and writes a version that is never edited afterwards.AWS ACCOUNTRange dataproduct lines andtheir componentsSpecificationssupplier sheets,drawings, emailsSales exportsunits and customers,monthlyS3 + EventBridgeimports and theperiod closeLambda x5spec, oblige, tonnage,split, submitDynamoDB x3components, weights,periodsingroundsouteu-west-2. One account. Specifications are read as they arrive; sales land monthly; the period close freezes everything and writes a version that is never edited afterwards.
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
  • Storage
  • Database

Region and account

  • Region: eu-west-2. London, because the scheme is a UK one and the nation split it reports on is a UK concept. Bedrock model availability is checked here rather than assumed; the specification read is the only step that would have to move, and every other step is multiplication.
  • 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
pkg-specS3 put, specifications prefixReads material, component and grams out of a supplier document60s / 1024MB
pkg-obligeOn range changeRuns the activity test per line and records which activity applied120s / 1024MB
pkg-tonnageMonthly, after the sales importMultiplies units by per-unit grams and sums by material600s / 3008MB
pkg-splitStep after tonnageClassifies each sales line household or not, with its evidence600s / 2048MB
pkg-submitEventBridge, on period closeFreezes inputs, reconciles, writes the file and the evidence pack900s / 3008MB

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
pkg-spec-rolebedrock:InvokeModel, s3:GetObject, dynamodb:PutItemOne model id; the specifications prefix; weights
pkg-oblige-roledynamodb:Query, dynamodb:UpdateItemcomponents read and write. No access to sales at all
pkg-tonnage-roles3:GetObject, dynamodb:Query, dynamodb:PutItemThe sales prefix; components and weights read; periods write
pkg-split-roles3:GetObject, dynamodb:Query, dynamodb:PutItemThe sales prefix; components read; periods write
pkg-submit-roledynamodb:Query, s3:PutObject, ses:SendEmailAll three tables read; the submissions prefix write-once; 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: components

PK   org_id#product_code S
SK   component_id        S   primary | secondary | transit | shipment + seq
     materials           L   [{material, portion}] — a list, not a field
     activity            S   brand_owner | filler | importer | distributor |
                             seller | none
     activity_basis      S   what established it, and the document
     counterparty        S   who reports it instead, when activity is none
     intra_group         BOOL true where it never reached the market
     assessed_under      S   the rules version that produced the activity

materials is a list because a card sleeve with a plastic window is two
materials in fixed proportions, and they are charged differently. A
single material field forces a lie on every composite in the range.

Table: weights

PK   org_id#product_code#component_id S
SK   established_on                   S   append-only; newest wins
     grams                            N   per unit, empty packaging
     method                           S   supplier_spec | weighed | sampled
     sample                           M   {n, mean, spread} when sampled
     evidence_key                     S   the spec, the weighing or sample sheet
     superseded_reason                S   why this replaced the previous figure

Weights are versioned by date rather than overwritten, because a period
already submitted used the figure that was current then. A supplier
spec arriving in March does not retrospectively change last year’s
tonnage; it starts a new version and the reconciliation explains it.

Table: periods

PK   org_id#period       S   e.g. 2026-H1
SK   version#row_id      S   version 1 is never edited
     material            S
     split               S   household | non_household
     nation              S   set on household rows only
     tonnes              N
     source_lines        N   how many sales lines contributed
     weight_versions     L   the weight records this row multiplied
     state               S   draft | frozen | submitted | superseded
     variance_note       S   set where the reconciliation flagged a move

weight_versions is what makes the audit trace in part 5 a query rather
than an investigation. Without it, a submitted tonne can name the sales
lines behind it but not the gram figure it was multiplied by, and the
trail stops one step short of the document.

Inbound and outbound

  • Sales exports are parsed, not read by a model. They are millions of fixed-header rows a month and they are the largest thing this system touches. A model over them would cost more than the fees being calculated.
  • Specifications arrive by upload or mailbox and both land in the same function. A supplier’s PDF drawing and a supplier’s one-line email answer are the same document as far as extraction is concerned, and the email is more common.
  • An incomplete specification produces a chase, not a guess. The missing field goes on a list ranked by the tonnage currently resting on an estimate, which is the most useful output of the first year.
  • Nothing is submitted automatically. The close produces a file and a pack; a person files it, because a submission is a legal statement and pressing send is a decision.

The model call

  • One call per specification document. Nothing per unit, per sales line, per product or per period. Tonnage is a multiplication and a sum.
  • A mid-tier model. Material, component and grams out of a short document with unpredictable formatting is extraction, not reasoning.
  • Units are extracted, never converted by the model. A spec quoting 0.021 kg comes back as 0.021 and kg, and the conversion happens in code. A model asked to normalise will occasionally move a decimal point, and a decimal point here is a factor of ten across a million units.
  • Composites come back as a list. The prompt asks for material portions rather than a material, because the honest answer for half a range is two materials.
  • Absent means null. A spec that does not state a weight must produce null, which routes the component to the chase list. A plausible number here is indistinguishable from a real one three years later.

Things worth knowing before you build it

  • Classify the sale, not the product code. The same case is household packaging in a supermarket and non-household in a catering wholesaler, and a per-code field cannot hold both.
  • Store the method with every weight. An estimate is perfectly acceptable and an estimate nobody labelled is not, and the difference only becomes visible in an audit.
  • Weigh empty packaging rather than subtracting product weight from a full case. The shortcut puts the tin in the cardboard line.
  • Model a component’s materials as a list of portions. Composites are common, they are charged differently by material, and a single material field forces an error on every one of them.
  • Freeze the period at close and version corrections. Every input to packaging data keeps changing after the period it describes, so a recomputed figure is never the one you filed.
  • Reconcile against the previous period before filing. An unexplained ten per cent move is almost always a data error, and it is the only check available that does not need an auditor.

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

All posts