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
- 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
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 |
|---|---|---|---|
pkg-spec | S3 put, specifications prefix | Reads material, component and grams out of a supplier document | 60s / 1024MB |
pkg-oblige | On range change | Runs the activity test per line and records which activity applied | 120s / 1024MB |
pkg-tonnage | Monthly, after the sales import | Multiplies units by per-unit grams and sums by material | 600s / 3008MB |
pkg-split | Step after tonnage | Classifies each sales line household or not, with its evidence | 600s / 2048MB |
pkg-submit | EventBridge, on period close | Freezes inputs, reconciles, writes the file and the evidence pack | 900s / 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
| Role | Allowed | On |
|---|---|---|
pkg-spec-role | bedrock:InvokeModel, s3:GetObject, dynamodb:PutItem | One model id; the specifications prefix; weights |
pkg-oblige-role | dynamodb:Query, dynamodb:UpdateItem | components read and write. No access to sales at all |
pkg-tonnage-role | s3:GetObject, dynamodb:Query, dynamodb:PutItem | The sales prefix; components and weights read; periods write |
pkg-split-role | s3:GetObject, dynamodb:Query, dynamodb:PutItem | The sales prefix; components read; periods write |
pkg-submit-role | dynamodb:Query, s3:PutObject, ses:SendEmail | All 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