I dropped the package-copy shortcut during the rating engine’s development.
If a shipment contained ten parcels, I wanted ten parcel records, not one
record with copies: 10. Count-based pricing stayed. The alternative way of
describing the things being priced didn’t.
This was an earlier input-model decision, not another change made during the Go port. It matters to the rest of the engine because parcels acquire references: a transport leg carries particular parcels, an item allocation belongs to a particular parcel, and a package-scoped rule needs that parcel’s facts. A multiplier doesn’t answer those questions without more conventions.
The cost was a more verbose request. The benefit was one internal shape to follow through validation, preparation, matching, and pricing. Here’s where that choice actually appeared in the implementation.
A shipment total can’t tell us which parcel is heavy
Two shipments can both contain two parcels weighing ten kilograms in total. One has five kilograms in each parcel; the other has one and nine. Their package count and total weight agree. Their heaviest package doesn’t.
The ordinary request shape retained that difference. This is a package-list fragment, not a complete HTTP document with its required metadata, origin, and destination:
{
"packages": [
{"id": "A", "weight": {"value": 1, "unit": "kg"}},
{"id": "B", "weight": {"value": 9, "unit": "kg"}}
]
}The runtime geometry resolver normalized package weights to kilograms. The shipment aggregator then produced these values for the two explicit lists:
| Measure | Even | Uneven |
|---|---|---|
| Count | 2 | 2 |
| Total kg | 10 | 10 |
| Max kg | 5 | 9 |
Those results come from executing the actual geometry and aggregate helpers. The count wasn’t estimated from weight, and the maximum wasn’t reconstructed from an average. A rule matching a package above eight kilograms could distinguish the shipments because the input still contained the individual facts.
The small public model adds a fictional tariff to make the consequence visible: 12 currency units per shipment, plus three for each parcel above eight kilograms, gives 12 versus 15. It also checks the fair counterexample. Two genuinely identical five-kilogram parcels can be expanded from a copy instruction without changing that calculation. The shorthand isn’t inherently lossy; it becomes lossy when it stands in for differences which the engine needs.
The opening calculation explains derived and chargeable weight. This decision comes before those policies: keep the facts at the level where a rule may need to distinguish them, instead of asking later calculations to invent a distribution.
Identity became part of the prepared pricing context
The package request rules used an allowlist. Alongside id, weight, dimensions,
and items, it admitted fields for goods and package classifications; it had
no copies member. Package IDs were required strings with a distinct rule.
The source’s generic rule validator rejected a duplicate ID or an added
copies key in the otherwise valid control request.
The runtime indexer didn’t repeat all those validation rules. It built the views later package-scoped pricing could use. This is the package part of the actual loop, with the item loop left out:
$packageCount = count($packages);
foreach ($packages as $packageIndex => $package) {
$packageId = $this->values->stringValue($package, 'id');
if ($packageId === null) {
throw NormalizedShipmentPackageMissingIdException::create();
}
$indexedPackages[$packageId] = [
'package' => $package,
'position' => $packageIndex + 1,
'count' => $packageCount,
];
}The context received shipment.packages_by_id. Parcel B in the uneven
example had position two, package count two, and its own nine-kilogram
measurement. A first-package rule and an additional-package rule could use
position/count without confusing either with physical weight.
Items had a second index, keyed by both the package ID and item ID:
$indexedItems[$packageId][$itemId] = [
'package' => $package,
'item' => $item,
'position' => $itemIndex + 1,
'count' => $packageItemCount,
];That pair mattered when one shipment item was allocated across multiple packages. An item reference inside A wasn’t interchangeable with the same item reference inside B. The prepared entry carried both the item and its package, rather than expecting a later matcher to rediscover the relationship.
The indexer’s assumptions were visible too. Bypassing admission and giving two packages the same ID overwrote the earlier map entry while the stored count still described the original two-element list. Missing identity raised an exception, but duplicate identity wasn’t refused by this helper itself. That is why the upstream distinctness check matters. An internal index isn’t a second complete request validator, and the public model’s stricter rejection checks shouldn’t be mistaken for this helper’s contract.
A leg selected records, not a fraction of a multiplier
Suppose A and B start together, then only B continues on another leg. For
the uneven shipment, that continuing leg carries the nine-kilogram parcel.
Its package_ids can say so directly. “One of two copies” needs another
identity scheme before it says anything equally useful.
The resolver built a package-ID map from the shipment, then followed the leg’s explicit references. Its final selection loop was:
foreach ($packageIds as $packageId) {
if (!array_key_exists($packageId, $packageMap)) {
throw LegPackageReferenceUnresolvedException::create();
}
$resolvedPackages[] = $packageMap[$packageId];
}This retained reference order and returned the original selected records.
Resolving package_ids: ["B"] selected B; aggregating that slice gave one
package and nine kilograms. The resolver didn’t multiply a representative
package by a leg-specific quantity. When given no package references, it
returned an empty slice, not an implicit instruction to carry everything.
That empty behavior doesn’t describe an ordinary accepted leg. The generic leg rules required a nonempty list of package IDs and checked membership against the shipment’s package IDs. Other leg fields and shipment-structure checks had their own requirements. The direct helper can be exercised with a reference fragment; that fragment isn’t a complete admitted shipment.
An adapter could generate IDs while expanding a copy instruction. There is nothing wrong with that, provided it does so before legs or allocations need those IDs, and it uses the same identities everywhere. Keeping the multiplier inside evaluation postpones the expansion problem until more parts of the engine depend on the answer.
An allocation carried a different quantity
A shipment could describe an item once and allocate it across packages. For 100 item units split 60/40, the relevant allocation fragment was:
{
"package_item_allocations": [
{
"package_id": "A",
"item_id": "item-1",
"quantity": {"value": 60, "unit": "pcs"}
},
{
"package_id": "B",
"item_id": "item-1",
"quantity": {"value": 40, "unit": "pcs"}
}
]
}The shipment also needed the referenced root item and package records. Those
references were checked by generic in_array rules against the corresponding
IDs. Package count was still two. One hundred item units didn’t imply one
hundred physical parcels.
The allocation hydrator copied the root item into the appropriate package context and attached its allocation separately:
$allocatedItem = $itemsById[$itemId];
$allocatedItem['package_id'] = $packageId;
if (is_array($allocation['quantity'] ?? null)) {
$allocatedItem['allocated_quantity'] = $allocation['quantity'];
}
$allocatedItemsByPackageId[$packageId][] = $allocatedItem;It didn’t overwrite the root item’s declared quantity. In the example, the
item copied into A still carried the original 100-unit quantity alongside
an allocated_quantity of 60. The copy in B carried an allocation of 40.
A consumer has to choose the quantity it means; treating the original item
quantity as each package’s allocation would count something different.
The hydrator filled packages which didn’t already have a nonempty nested item list. Existing nested items took precedence. It also skipped malformed or unresolved allocation entries rather than acting as a complete admission layer itself. Direct execution makes that distinction clear: generic request validation refused an unknown item reference, while the hydrator alone ignored that unresolved entry.
Neither copying an allocation nor checking its reference proves that all allocated quantities conserve the item’s total. This chapter follows the representation and hydration boundary, not every validation and pricing consumer. Keeping quantity attached to an explicit item/package relationship makes those checks possible without pretending they have all happened here.
Counting parcels didn’t require another input shape
The shipment aggregator began with the number of explicit package records, then merged metrics resolved for each package:
$aggregate = $this->emptyAggregate(count($packages));
foreach ($packages as $package) {
$packageMetrics = $resolvePackageMetrics($package);
$aggregate = $this->mergePackageMetrics(
$aggregate,
$package,
$packageMetrics,
);
}That provided package_count alongside totals and maxima. Count-based tariffs
could still use the count. Package-scoped rules could still use position and
the original package facts. Dropping copies didn’t remove either pricing
capability; it stopped making the evaluator support two representations of
the same set of parcels.
Explicit records weren’t a promise of complete measurements. The ordinary generic rules allowed missing weight and dimensions. With one package and no weight, the native aggregate still counted one package; its weight sum started at zero while its maximum package weight stayed null. That doesn’t establish a physical zero-kilogram parcel or a complete shipment-weight fact. Consumers need to respect the missing-data boundary rather than borrowing confidence from the presence of an ID.
Requiring an explicit list also has a cost. A caller sending many identical boxes has to produce repeated records. An authoring interface can offer “add ten identical boxes” without exposing the evaluator to a multiplier, but it must expand them into separately addressable records. There was no reason to make someone type the measurements ten times just because the internal model needed ten identities.
If the system only priced homogeneous, independent units and never referenced individual ones, a count could be the simpler core model. At very large homogeneous quantities, explicit expansion also has a payload and processing cost worth measuring. A compact model could be justified there, but its consumers would need clear rules for membership, identity, differences, and aggregation. Shorter JSON doesn’t settle those rules.
For this engine, I kept the explicit list and put any copy convenience before it. I wanted the later pricing work to start with identifiable facts, not another opportunity to disagree about what a quantity multiplied.