@packvium/engine 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/commerce.js ADDED
@@ -0,0 +1,664 @@
1
+ /**
2
+ * Packvium's exported commercial and control-plane API for JavaScript.
3
+ *
4
+ * Three deterministic functions over one canonical JSON document: a quote, a policy
5
+ * decision and catalog version metadata. The contract -- document format, result
6
+ * shapes, the closed set of rejection codes, complexity and limitations -- is
7
+ * docs/COMMERCE-API.md.
8
+ *
9
+ * Parsing is strict in both directions: a missing required key and an unrecognised
10
+ * extra key are both a CommerceInputError, because a field the contract does not define
11
+ * must never be silently ignored. A well-formed request the commercial model simply
12
+ * cannot answer is not an error at all -- it is a result document whose status is
13
+ * "rejected", the same way an infeasible packing request returns a result with a status.
14
+ *
15
+ * This module is package-internal: package.json exports only the root entry point,
16
+ * which re-exports these as `commerce`.
17
+ */
18
+
19
+ import {
20
+ CommerceInputError,
21
+ compareCodePoints,
22
+ POLICY_ACTIONS,
23
+ POLICY_OPERATORS,
24
+ POLICY_SCOPES,
25
+ decide,
26
+ effectiveVersion,
27
+ isUnary,
28
+ rateTariff,
29
+ } from './commerce-model.js';
30
+
31
+ export { CommerceInputError };
32
+
33
+ export const API_VERSION = 1;
34
+
35
+ /** The closed set of rejection codes, in the order docs/COMMERCE-API.md tabulates them. */
36
+ export const REJECTION_CODES = [
37
+ 'tariff_not_found',
38
+ 'no_effective_tariff',
39
+ 'unavailable_zone',
40
+ 'unavailable_accessorial',
41
+ 'policy_rule_not_found',
42
+ 'policy_version_not_found',
43
+ 'catalog_not_found',
44
+ 'catalog_version_not_found',
45
+ 'no_effective_catalog_version',
46
+ 'ambiguous_catalog_reference',
47
+ ];
48
+
49
+ const EXCLUSION_SCOPES = ['item_carton', 'item_pallet'];
50
+ const OVERRIDE_KINDS = ['carton', 'item', 'pallet'];
51
+
52
+ // ------------------------------------------------------------------- shape primitives
53
+
54
+ function fail(path, message) {
55
+ throw new CommerceInputError(`${path}: ${message}`);
56
+ }
57
+
58
+ function asObject(value, path) {
59
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
60
+ fail(path, 'expected an object');
61
+ }
62
+ return value;
63
+ }
64
+
65
+ function asList(value, path) {
66
+ if (!Array.isArray(value)) fail(path, 'expected a list');
67
+ return value;
68
+ }
69
+
70
+ function asInteger(value, path) {
71
+ // A JSON boolean where an exact integer belongs is a caller mistake, not a 0 or a 1.
72
+ if (typeof value !== 'number' || !Number.isInteger(value)) {
73
+ fail(path, 'expected an exact integer');
74
+ }
75
+ return value;
76
+ }
77
+
78
+ function asText(value, path) {
79
+ if (typeof value !== 'string') fail(path, 'expected a string');
80
+ return value;
81
+ }
82
+
83
+ function checkKeys(value, path, required, optionalKeys = []) {
84
+ const missing = required.filter((key) => !Object.hasOwn(value, key)).sort();
85
+ if (missing.length > 0) fail(path, `missing required key(s) ${JSON.stringify(missing)}`);
86
+ const unknown = Object.keys(value)
87
+ .filter((key) => !required.includes(key) && !optionalKeys.includes(key))
88
+ .sort();
89
+ if (unknown.length > 0) fail(path, `unrecognised key(s) ${JSON.stringify(unknown)}`);
90
+ }
91
+
92
+ /** An absent or explicitly-null optional field reads as absent, in every language. */
93
+ function optional(value, key) {
94
+ const found = value[key];
95
+ return found === undefined || found === null ? undefined : found;
96
+ }
97
+
98
+ function asAxes(value, path, count) {
99
+ const entries = asList(value, path);
100
+ if (entries.length !== count) fail(path, `expected exactly ${count} axes`);
101
+ return entries.map((entry, index) => asInteger(entry, `${path}[${index}]`));
102
+ }
103
+
104
+ function asEnum(value, path, allowed, label) {
105
+ const found = asText(value, path);
106
+ if (!allowed.includes(found)) fail(path, `unsupported ${label} '${found}'`);
107
+ return found;
108
+ }
109
+
110
+ function positive(value, path, message) {
111
+ if (value <= 0) fail(path, message);
112
+ return value;
113
+ }
114
+
115
+ function nonNegative(value, path, message) {
116
+ if (value < 0) fail(path, message);
117
+ return value;
118
+ }
119
+
120
+ function requireUniqueIds(entries, label, path) {
121
+ const ids = entries.map((entry) => entry.id);
122
+ if (new Set(ids).size !== ids.length) fail(path, `duplicate ${label} ids in catalog snapshot`);
123
+ }
124
+
125
+ /**
126
+ * The shared shape of all three histories: a list of `{...identity, versions: [...]}`
127
+ * entries, keyed by identity, where a version's number is its 1-based position.
128
+ */
129
+ function loadHistories(value, path, identityKeys, label, parseVersion) {
130
+ const histories = new Map();
131
+ asList(value, path).forEach((entry, index) => {
132
+ const entryPath = `${path}[${index}]`;
133
+ const fields = asObject(entry, entryPath);
134
+ checkKeys(fields, entryPath, [...identityKeys, 'versions']);
135
+ const identity = identityKeys.map((name) => asText(fields[name], `${entryPath}.${name}`));
136
+ const key = identity.join('/');
137
+ if (histories.has(key)) fail(entryPath, `duplicate ${label} history for '${key}'`);
138
+ const versions = asList(fields.versions, `${entryPath}.versions`);
139
+ if (versions.length === 0) {
140
+ fail(`${entryPath}.versions`, `a ${label} history needs at least one version`);
141
+ }
142
+ const history = [];
143
+ versions.forEach((version, position) => {
144
+ history.push(
145
+ parseVersion(version, `${entryPath}.versions[${position}]`, identity, position + 1, history),
146
+ );
147
+ });
148
+ histories.set(key, history);
149
+ });
150
+ return histories;
151
+ }
152
+
153
+ // -------------------------------------------------------------------- document loading
154
+
155
+ /** Build the three append-only histories one canonical commerce document describes. */
156
+ export function loadDocument(document) {
157
+ const root = asObject(document, 'document');
158
+ checkKeys(root, 'document', [], ['tariffs', 'policy_rules', 'catalogs']);
159
+ return {
160
+ carriers: loadHistories(
161
+ optional(root, 'tariffs') ?? [], 'document.tariffs',
162
+ ['carrier_id', 'service_id'], 'tariff', parseTariff,
163
+ ),
164
+ policies: loadHistories(
165
+ optional(root, 'policy_rules') ?? [], 'document.policy_rules',
166
+ ['rule_id'], 'rule', parseRule,
167
+ ),
168
+ catalogs: loadHistories(
169
+ optional(root, 'catalogs') ?? [], 'document.catalogs',
170
+ ['catalog_id'], 'catalog', parseCatalogVersion,
171
+ ),
172
+ };
173
+ }
174
+
175
+ function parseTariff(value, path, [carrierId, serviceId], number) {
176
+ const fields = asObject(value, path);
177
+ checkKeys(
178
+ fields, path,
179
+ ['effective_at', 'dimensional_weight_divisor', 'cost_per_dimensional_kg_minor'],
180
+ ['minimum_charge_minor', 'fuel_surcharge_permille', 'accessorials'],
181
+ );
182
+ const zonesPath = `${path}.cost_per_dimensional_kg_minor`;
183
+ const costPerDimensionalKgMinor = {};
184
+ const zones = asObject(fields.cost_per_dimensional_kg_minor, zonesPath);
185
+ for (const [zone, cost] of Object.entries(zones)) {
186
+ costPerDimensionalKgMinor[zone] = nonNegative(
187
+ asInteger(cost, `${zonesPath}[${zone}]`), zonesPath,
188
+ 'cost_per_dimensional_kg_minor entries cannot be negative',
189
+ );
190
+ }
191
+ return {
192
+ carrierId,
193
+ serviceId,
194
+ version: number,
195
+ effectiveAt: nonNegative(
196
+ asInteger(fields.effective_at, `${path}.effective_at`),
197
+ path, 'effective_at cannot be negative',
198
+ ),
199
+ dimensionalWeightDivisor: positive(
200
+ asInteger(fields.dimensional_weight_divisor, `${path}.dimensional_weight_divisor`),
201
+ path, 'dimensional_weight_divisor must be positive',
202
+ ),
203
+ costPerDimensionalKgMinor,
204
+ minimumChargeMinor: nonNegative(
205
+ asInteger(optional(fields, 'minimum_charge_minor') ?? 0, `${path}.minimum_charge_minor`),
206
+ path, 'minimum_charge_minor cannot be negative',
207
+ ),
208
+ fuelSurchargePermille: nonNegative(
209
+ asInteger(optional(fields, 'fuel_surcharge_permille') ?? 0, `${path}.fuel_surcharge_permille`),
210
+ path, 'fuel_surcharge_permille cannot be negative',
211
+ ),
212
+ accessorials: parseAccessorials(optional(fields, 'accessorials') ?? [], `${path}.accessorials`),
213
+ };
214
+ }
215
+
216
+ function parseAccessorials(value, path) {
217
+ const charges = {};
218
+ asList(value, path).forEach((entry, index) => {
219
+ const entryPath = `${path}[${index}]`;
220
+ const fields = asObject(entry, entryPath);
221
+ checkKeys(fields, entryPath, ['accessorial_id'], ['flat_charge_minor', 'permille_of_base']);
222
+ const id = asText(fields.accessorial_id, `${entryPath}.accessorial_id`);
223
+ if (Object.hasOwn(charges, id)) fail(entryPath, `duplicate accessorial_id '${id}'`);
224
+ const flat = optional(fields, 'flat_charge_minor');
225
+ const permille = optional(fields, 'permille_of_base');
226
+ if ((flat === undefined) === (permille === undefined)) {
227
+ fail(entryPath, 'an accessorial must set exactly one of flat_charge_minor or permille_of_base');
228
+ }
229
+ charges[id] = {
230
+ accessorialId: id,
231
+ flatChargeMinor: flat === undefined ? null : nonNegative(
232
+ asInteger(flat, `${entryPath}.flat_charge_minor`),
233
+ entryPath, 'flat_charge_minor cannot be negative',
234
+ ),
235
+ permilleOfBase: permille === undefined ? null : nonNegative(
236
+ asInteger(permille, `${entryPath}.permille_of_base`),
237
+ entryPath, 'permille_of_base cannot be negative',
238
+ ),
239
+ };
240
+ });
241
+ return charges;
242
+ }
243
+
244
+ function parseRule(value, path, [ruleId], number) {
245
+ const fields = asObject(value, path);
246
+ checkKeys(fields, path, ['scope', 'action', 'predicates', 'priority', 'effective_at'], ['reason']);
247
+ const scope = asEnum(fields.scope, `${path}.scope`, POLICY_SCOPES, 'policy scope');
248
+ const predicates = parsePredicates(fields.predicates, `${path}.predicates`, scope);
249
+ if (predicates.length === 0) fail(path, 'a rule must have at least one predicate');
250
+ return {
251
+ ruleId,
252
+ version: number,
253
+ scope,
254
+ action: asEnum(fields.action, `${path}.action`, POLICY_ACTIONS, 'policy action'),
255
+ predicates,
256
+ priority: asInteger(fields.priority, `${path}.priority`),
257
+ effectiveAt: nonNegative(
258
+ asInteger(fields.effective_at, `${path}.effective_at`),
259
+ path, 'effective_at cannot be negative',
260
+ ),
261
+ reason: asText(optional(fields, 'reason') ?? '', `${path}.reason`),
262
+ };
263
+ }
264
+
265
+ function parsePredicates(value, path, scope) {
266
+ return asList(value, path).map((entry, index) => {
267
+ const entryPath = `${path}[${index}]`;
268
+ const fields = asObject(entry, entryPath);
269
+ checkKeys(fields, entryPath, ['scope', 'field', 'operator'], ['value']);
270
+ if (asEnum(fields.scope, `${entryPath}.scope`, POLICY_SCOPES, 'policy scope') !== scope) {
271
+ fail(entryPath, "every predicate of a rule must share the rule's own scope");
272
+ }
273
+ const operator = asEnum(
274
+ fields.operator, `${entryPath}.operator`, POLICY_OPERATORS, 'policy operator',
275
+ );
276
+ const predicateValue = optional(fields, 'value') ?? null;
277
+ if (!isUnary(operator) && predicateValue === null) {
278
+ fail(entryPath, `operator '${operator}' requires a value`);
279
+ }
280
+ const field = asText(fields.field, `${entryPath}.field`);
281
+ if (field === '') fail(entryPath, 'field is required');
282
+ return { scope, field, operator, value: predicateValue };
283
+ });
284
+ }
285
+
286
+ function parseCatalogVersion(value, path, _identity, number, history) {
287
+ const fields = asObject(value, path);
288
+ if (Object.hasOwn(fields, 'rollback_to')) return parseRollback(fields, path, number, history);
289
+ checkKeys(fields, path, ['effective_at', 'published_at', 'snapshot'], ['note']);
290
+ return {
291
+ number,
292
+ snapshot: parseSnapshot(fields.snapshot, `${path}.snapshot`),
293
+ effectiveAt: nonNegative(
294
+ asInteger(fields.effective_at, `${path}.effective_at`),
295
+ path, 'effective_at cannot be negative',
296
+ ),
297
+ publishedAt: nonNegative(
298
+ asInteger(fields.published_at, `${path}.published_at`),
299
+ path, 'published_at cannot be negative',
300
+ ),
301
+ rolledBackFrom: null,
302
+ note: asText(optional(fields, 'note') ?? '', `${path}.note`),
303
+ };
304
+ }
305
+
306
+ /** A rollback is a new, higher-numbered version whose snapshot equals a prior one's. */
307
+ function parseRollback(fields, path, number, history) {
308
+ checkKeys(fields, path, ['rollback_to', 'published_at'], ['effective_at', 'note']);
309
+ const toVersion = asInteger(fields.rollback_to, `${path}.rollback_to`);
310
+ const target = history.find((version) => version.number === toVersion);
311
+ if (target === undefined) {
312
+ fail(path, `rollback_to names version ${toVersion}, which is not published yet`);
313
+ }
314
+ const publishedAt = asInteger(fields.published_at, `${path}.published_at`);
315
+ const note = asText(optional(fields, 'note') ?? '', `${path}.note`);
316
+ return {
317
+ number,
318
+ snapshot: target.snapshot,
319
+ effectiveAt: asInteger(optional(fields, 'effective_at') ?? publishedAt, `${path}.effective_at`),
320
+ publishedAt,
321
+ rolledBackFrom: toVersion,
322
+ note: note === '' ? `rollback to version ${toVersion}` : note,
323
+ };
324
+ }
325
+
326
+ function parseSnapshot(value, path) {
327
+ const fields = asObject(value, path);
328
+ checkKeys(fields, path, [], ['items', 'cartons', 'pallets', 'exclusions', 'overrides']);
329
+ const collect = (key, parse) => asList(optional(fields, key) ?? [], `${path}.${key}`)
330
+ .map((entry, index) => parse(
331
+ asObject(entry, `${path}.${key}[${index}]`), `${path}.${key}[${index}]`,
332
+ ));
333
+
334
+ const snapshot = {
335
+ items: collect('items', parseItem),
336
+ cartons: collect('cartons', parseCarton),
337
+ pallets: collect('pallets', parsePallet),
338
+ exclusions: collect('exclusions', parseExclusion),
339
+ overrides: collect('overrides', parseOverride),
340
+ };
341
+ requireUniqueIds(snapshot.items, 'item', path);
342
+ requireUniqueIds(snapshot.cartons, 'carton', path);
343
+ requireUniqueIds(snapshot.pallets, 'pallet', path);
344
+ requireUniqueIds(snapshot.exclusions, 'exclusion', path);
345
+ requireUniqueIds(snapshot.overrides, 'facility override', path);
346
+ return snapshot;
347
+ }
348
+
349
+ function identifier(fields, path, label) {
350
+ const id = asText(fields.id, `${path}.id`);
351
+ if (id === '') fail(path, `${label} id is required`);
352
+ return id;
353
+ }
354
+
355
+ function parseItem(fields, path) {
356
+ checkKeys(fields, path, ['id', 'dimensions_mm', 'weight_g'], ['description']);
357
+ const dimensions = asAxes(fields.dimensions_mm, `${path}.dimensions_mm`, 3);
358
+ if (dimensions.some((axis) => axis <= 0)) fail(path, 'item dimensions must be positive');
359
+ return {
360
+ id: identifier(fields, path, 'item'),
361
+ dimensionsMm: dimensions,
362
+ weightG: positive(
363
+ asInteger(fields.weight_g, `${path}.weight_g`), path, 'item weight must be positive',
364
+ ),
365
+ description: asText(optional(fields, 'description') ?? '', `${path}.description`),
366
+ };
367
+ }
368
+
369
+ function parseCarton(fields, path) {
370
+ checkKeys(fields, path, ['id', 'inner_dimensions_mm', 'max_payload_g'], ['cost_minor']);
371
+ const dimensions = asAxes(fields.inner_dimensions_mm, `${path}.inner_dimensions_mm`, 3);
372
+ if (dimensions.some((axis) => axis <= 0)) fail(path, 'carton dimensions must be positive');
373
+ return {
374
+ id: identifier(fields, path, 'carton'),
375
+ innerDimensionsMm: dimensions,
376
+ maxPayloadG: positive(
377
+ asInteger(fields.max_payload_g, `${path}.max_payload_g`),
378
+ path, 'carton max_payload_g must be positive',
379
+ ),
380
+ costMinor: nonNegative(
381
+ asInteger(optional(fields, 'cost_minor') ?? 0, `${path}.cost_minor`),
382
+ path, 'cost_minor cannot be negative',
383
+ ),
384
+ };
385
+ }
386
+
387
+ function parsePallet(fields, path) {
388
+ checkKeys(fields, path, ['id', 'deck_dimensions_mm', 'max_payload_g'], ['max_stack_height_mm']);
389
+ const deck = asAxes(fields.deck_dimensions_mm, `${path}.deck_dimensions_mm`, 2);
390
+ if (deck.some((axis) => axis <= 0)) fail(path, 'pallet dimensions must be positive');
391
+ const height = optional(fields, 'max_stack_height_mm');
392
+ return {
393
+ id: identifier(fields, path, 'pallet'),
394
+ deckDimensionsMm: deck,
395
+ maxPayloadG: positive(
396
+ asInteger(fields.max_payload_g, `${path}.max_payload_g`),
397
+ path, 'pallet max_payload_g must be positive',
398
+ ),
399
+ maxStackHeightMm: height === undefined ? null : positive(
400
+ asInteger(height, `${path}.max_stack_height_mm`),
401
+ path, 'max_stack_height_mm must be positive',
402
+ ),
403
+ };
404
+ }
405
+
406
+ function parseExclusion(fields, path) {
407
+ checkKeys(fields, path, ['id', 'scope', 'subject_id', 'excluded_id'], ['reason']);
408
+ const subjectId = asText(fields.subject_id, `${path}.subject_id`);
409
+ const excludedId = asText(fields.excluded_id, `${path}.excluded_id`);
410
+ if (subjectId === '' || excludedId === '') {
411
+ fail(path, 'an exclusion rule must reference both a subject and an excluded id');
412
+ }
413
+ return {
414
+ id: identifier(fields, path, 'exclusion'),
415
+ scope: asEnum(fields.scope, `${path}.scope`, EXCLUSION_SCOPES, 'exclusion scope'),
416
+ subjectId,
417
+ excludedId,
418
+ reason: asText(optional(fields, 'reason') ?? '', `${path}.reason`),
419
+ };
420
+ }
421
+
422
+ function parseOverride(fields, path) {
423
+ checkKeys(fields, path, ['id', 'facility_id', 'entry_id', 'kind', 'override']);
424
+ const kind = asEnum(fields.kind, `${path}.kind`, OVERRIDE_KINDS, 'override kind');
425
+ const parse = { item: parseItem, carton: parseCarton, pallet: parsePallet }[kind];
426
+ const entry = parse(asObject(fields.override, `${path}.override`), `${path}.override`);
427
+ const facilityId = asText(fields.facility_id, `${path}.facility_id`);
428
+ const entryId = asText(fields.entry_id, `${path}.entry_id`);
429
+ if (facilityId === '') fail(path, 'facility_id is required');
430
+ if (entry.id !== entryId) fail(path, "a facility override's entry_id must match override.id");
431
+ return { id: identifier(fields, path, 'facility override'), facilityId, entryId, kind, entry };
432
+ }
433
+
434
+ // --------------------------------------------------------------------------- responses
435
+
436
+ function ok(key, payload) {
437
+ return { api_version: API_VERSION, status: 'ok', [key]: payload };
438
+ }
439
+
440
+ function rejected(code, fields) {
441
+ return { api_version: API_VERSION, status: 'rejected', error: { code, fields } };
442
+ }
443
+
444
+ function exactlyOne(request, names) {
445
+ const present = names.filter((name) => optional(request, name) !== undefined);
446
+ if (present.length !== 1) fail('request', `expected exactly one of ${JSON.stringify(names)}`);
447
+ return present[0];
448
+ }
449
+
450
+ // ------------------------------------------------------------------------------- quote
451
+
452
+ /** Price one shipment against one pinned or effective-dated tariff version. */
453
+ export function quote(document, request) {
454
+ const loaded = loadDocument(document);
455
+ const fields = asObject(request, 'request');
456
+ checkKeys(
457
+ fields, 'request',
458
+ ['carrier_id', 'service_id', 'zone', 'actual_weight_g', 'volume_mm3'],
459
+ ['tariff_version', 'as_of', 'requested_accessorials'],
460
+ );
461
+ const pin = exactlyOne(fields, ['tariff_version', 'as_of']);
462
+ const carrierId = asText(fields.carrier_id, 'request.carrier_id');
463
+ const serviceId = asText(fields.service_id, 'request.service_id');
464
+ const ratingRequest = parseRatingRequest(fields);
465
+ const identity = { carrier_id: carrierId, service_id: serviceId };
466
+
467
+ const resolved = resolveTariff(loaded.carriers, identity, fields, pin);
468
+ if (resolved.rejection !== undefined) {
469
+ return rejected(resolved.rejection.code, resolved.rejection.fields);
470
+ }
471
+
472
+ const { tariff } = resolved;
473
+ const { breakdown, rejection } = rateTariff(tariff, ratingRequest);
474
+ if (rejection !== undefined) {
475
+ const where = { ...identity, tariff_version: tariff.version };
476
+ return rejection.kind === 'zone'
477
+ ? rejected('unavailable_zone', { ...where, zone: rejection.zone })
478
+ : rejected('unavailable_accessorial', { ...where, accessorial_ids: rejection.accessorialIds });
479
+ }
480
+ return ok('quote', breakdown);
481
+ }
482
+
483
+ function resolveTariff(carriers, identity, fields, pin) {
484
+ const history = carriers.get(`${identity.carrier_id}/${identity.service_id}`);
485
+ if (pin === 'tariff_version') {
486
+ const version = asInteger(fields.tariff_version, 'request.tariff_version');
487
+ const tariff = history?.find((candidate) => candidate.version === version);
488
+ if (tariff === undefined) {
489
+ return { rejection: { code: 'tariff_not_found', fields: { ...identity, tariff_version: version } } };
490
+ }
491
+ return { tariff };
492
+ }
493
+ const asOf = asInteger(fields.as_of, 'request.as_of');
494
+ if (history === undefined) {
495
+ return { rejection: { code: 'tariff_not_found', fields: identity } };
496
+ }
497
+ const tariff = effectiveVersion(history, asOf, (candidate) => candidate.version);
498
+ if (tariff === null) {
499
+ return { rejection: { code: 'no_effective_tariff', fields: { ...identity, as_of: asOf } } };
500
+ }
501
+ return { tariff };
502
+ }
503
+
504
+ function parseRatingRequest(fields) {
505
+ const requestedAccessorials = asList(
506
+ optional(fields, 'requested_accessorials') ?? [], 'request.requested_accessorials',
507
+ ).map((entry, index) => asText(entry, `request.requested_accessorials[${index}]`));
508
+ if (requestedAccessorials.some((id) => id === '')) {
509
+ fail('request.requested_accessorials', 'accessorial ids must be non-empty');
510
+ }
511
+ if (new Set(requestedAccessorials).size !== requestedAccessorials.length) {
512
+ fail('request.requested_accessorials', 'accessorial ids must be unique');
513
+ }
514
+ const zone = asText(fields.zone, 'request.zone');
515
+ if (zone === '') fail('request.zone', 'zone is required');
516
+ return {
517
+ zone,
518
+ actualWeightG: nonNegative(
519
+ asInteger(fields.actual_weight_g, 'request.actual_weight_g'),
520
+ 'request.actual_weight_g', 'actual_weight_g cannot be negative',
521
+ ),
522
+ volumeMm3: nonNegative(
523
+ asInteger(fields.volume_mm3, 'request.volume_mm3'),
524
+ 'request.volume_mm3', 'volume_mm3 cannot be negative',
525
+ ),
526
+ requestedAccessorials,
527
+ };
528
+ }
529
+
530
+ // --------------------------------------------------------------------- evaluate policy
531
+
532
+ /** Decide one eligibility question against a pinned or effective-dated rule set. */
533
+ export function evaluatePolicy(document, request) {
534
+ const loaded = loadDocument(document);
535
+ const fields = asObject(request, 'request');
536
+ checkKeys(fields, 'request', ['scope', 'context'], ['as_of', 'rule_versions']);
537
+ const pin = exactlyOne(fields, ['as_of', 'rule_versions']);
538
+ const scope = asEnum(fields.scope, 'request.scope', POLICY_SCOPES, 'policy scope');
539
+ const context = asObject(fields.context, 'request.context');
540
+
541
+ if (pin === 'as_of') {
542
+ const asOf = asInteger(fields.as_of, 'request.as_of');
543
+ const effective = [...loaded.policies.values()]
544
+ .map((history) => effectiveVersion(history, asOf, (rule) => rule.version))
545
+ .filter((rule) => rule !== null);
546
+ return ok('decision', decide(effective, scope, context));
547
+ }
548
+ const resolved = resolvePins(loaded.policies, fields.rule_versions);
549
+ if (resolved.rejection !== undefined) {
550
+ return rejected(resolved.rejection.code, resolved.rejection.fields);
551
+ }
552
+ return ok('decision', decide(resolved.rules, scope, context));
553
+ }
554
+
555
+ function resolvePins(policies, value) {
556
+ const pins = asList(value, 'request.rule_versions').map((entry, index) => {
557
+ const path = `request.rule_versions[${index}]`;
558
+ const pair = asList(entry, path);
559
+ if (pair.length !== 2) fail(path, 'expected a [rule_id, version] pair');
560
+ return [asText(pair[0], `${path}[0]`), asInteger(pair[1], `${path}[1]`)];
561
+ });
562
+ if (new Set(pins.map(([ruleId]) => ruleId)).size !== pins.length) {
563
+ fail('request.rule_versions', 'a policy snapshot cannot pin the same rule id twice');
564
+ }
565
+ // Sorted so an explicit snapshot is order-independent, exactly as the reference
566
+ // implementation orders it before deciding.
567
+ pins.sort(([leftId, leftVersion], [rightId, rightVersion]) => (
568
+ leftId === rightId ? leftVersion - rightVersion : compareCodePoints(leftId, rightId)
569
+ ));
570
+
571
+ const rules = [];
572
+ for (const [ruleId, version] of pins) {
573
+ const history = policies.get(ruleId);
574
+ if (history === undefined) {
575
+ return { rejection: { code: 'policy_rule_not_found', fields: { rule_id: ruleId } } };
576
+ }
577
+ const rule = history.find((candidate) => candidate.version === version);
578
+ if (rule === undefined) {
579
+ return { rejection: { code: 'policy_version_not_found', fields: { rule_id: ruleId, version } } };
580
+ }
581
+ rules.push(rule);
582
+ }
583
+ return { rules };
584
+ }
585
+
586
+ // ---------------------------------------------------------------- catalog version info
587
+
588
+ /** Report which catalog version a reference resolves to, and what it contains. */
589
+ export function catalogVersionInfo(document, request) {
590
+ const loaded = loadDocument(document);
591
+ const fields = asObject(request, 'request');
592
+ checkKeys(fields, 'request', ['catalog_id', 'resolved_at'], ['version', 'as_of']);
593
+ const catalogId = asText(fields.catalog_id, 'request.catalog_id');
594
+ const resolvedAt = asInteger(fields.resolved_at, 'request.resolved_at');
595
+ const version = optional(fields, 'version') === undefined
596
+ ? undefined
597
+ : asInteger(fields.version, 'request.version');
598
+ const asOf = optional(fields, 'as_of') === undefined
599
+ ? undefined
600
+ : asInteger(fields.as_of, 'request.as_of');
601
+ if (version !== undefined && asOf !== undefined) {
602
+ fail('request', 'expected at most one of ["as_of","version"]');
603
+ }
604
+
605
+ const history = loaded.catalogs.get(catalogId);
606
+ if (history === undefined) return rejected('catalog_not_found', { catalog_id: catalogId });
607
+ const selector = { catalog_id: catalogId };
608
+ if (version !== undefined) selector.version = version;
609
+ if (asOf !== undefined) selector.as_of = asOf;
610
+
611
+ const resolved = resolveCatalogVersion(history, version, asOf);
612
+ if (typeof resolved === 'string') return rejected(resolved, selector);
613
+ return ok('catalog', catalogPayload(catalogId, resolved, resolvedAt));
614
+ }
615
+
616
+ function resolveCatalogVersion(history, version, asOf) {
617
+ if (version !== undefined) {
618
+ return history.find((candidate) => candidate.number === version) ?? 'catalog_version_not_found';
619
+ }
620
+ if (asOf === undefined) {
621
+ return history.length > 1 ? 'ambiguous_catalog_reference' : history[0];
622
+ }
623
+ return effectiveVersion(history, asOf, (candidate) => candidate.number)
624
+ ?? 'no_effective_catalog_version';
625
+ }
626
+
627
+ function catalogPayload(catalogId, version, resolvedAt) {
628
+ const { snapshot } = version;
629
+ // Sorted so no map or insertion ordering can leak into the answer.
630
+ const ids = (entries) => entries.map((entry) => entry.id).sort(compareCodePoints);
631
+ return {
632
+ catalog_id: catalogId,
633
+ version: version.number,
634
+ effective_at: version.effectiveAt,
635
+ published_at: version.publishedAt,
636
+ resolved_at: resolvedAt,
637
+ rolled_back_from: version.rolledBackFrom,
638
+ note: version.note,
639
+ entry_counts: {
640
+ items: snapshot.items.length,
641
+ cartons: snapshot.cartons.length,
642
+ pallets: snapshot.pallets.length,
643
+ exclusions: snapshot.exclusions.length,
644
+ overrides: snapshot.overrides.length,
645
+ },
646
+ item_ids: ids(snapshot.items),
647
+ carton_ids: ids(snapshot.cartons),
648
+ pallet_ids: ids(snapshot.pallets),
649
+ };
650
+ }
651
+
652
+ // ---------------------------------------------------------------------- canonical form
653
+
654
+ /** The one byte-comparable spelling of a result document: sorted keys, no padding. */
655
+ export function canonicalJson(result) {
656
+ const sort = (value) => {
657
+ if (Array.isArray(value)) return value.map(sort);
658
+ if (value === null || typeof value !== 'object') return value;
659
+ return Object.fromEntries(
660
+ Object.keys(value).sort(compareCodePoints).map((key) => [key, sort(value[key])]),
661
+ );
662
+ };
663
+ return JSON.stringify(sort(result));
664
+ }