August 2026
Every existing gate verified the site against its own contracts, counts and schemas — so a page could be schema-valid and still give a practitioner the wrong answer, which is exactly how the dry-ice miss happened. The new practitioner scenarios suite closes that class: real questions ("30 kg of dry ice on a van — is it under ADR and what must I still do?") with expected answers derived from the primary source — the purchased UNECE ADR 2025 Table A and volume texts, the WCO HS 2022 legal text, the UNECE UN/LOCODE directory, the US EPA GHG Emission Factors Hub, IATA TACT volumetric rules, the EU ICS2 stop-words list — asserted against the LIVE rendered output: page text as a reader sees it, API and calculator JSON bodies, hosted MCP tool results. Twenty-seven scenarios seed the suite across dangerous goods (scope verdicts, 1.1.3.6 points math, limited quantities, tunnel codes), the identifier resolver (ambiguity, byte-stability, honest zero-match), HS, UN/LOCODE withheld coordinates, emissions factor vintage, chargeable weight and ICS2 descriptions. Failures print the practitioner’s question, not a diff. The suite blocks in two tiers: offline in every build (api/calculator scenarios execute through the real route handlers), and live against every preview before merge plus production daily. Writing the seeds immediately paid for the machine: deriving expected answers from the regulation first surfaced three defects the site had been serving (see the fixes entry below).
First: ADR 1.1.3.6.3 note a. Nine transport category 1 rows — UN 0081, 0082, 0084, 0241, 0331, 0332, 0482, 1005 (anhydrous ammonia) and 1017 (chlorine) — score twenty points per kg under 1.1.3.6.4 with a 50 kg per-transport-unit cap under note a. Every surface applied the generic category-1 rule (fifty points, 20 kg cap) to them: 25 kg of anhydrous ammonia came back "1,250 points, not exempt" where the regulation gives 500 points, exempt. A conservative error, but the calculator, the UN pages and the MCP tool all stated the wrong limit and multiplier. The transport-category table now lives in one shared module with the note-a branch, every surface names the branch when it applies, and freightutils-mcp 2.17.2 aligns the tool description. Second: HS subheadings 8703.23 and 8703.24 still carried the HS 2017 word "reciprocating" under an "HS 2022" label — WCO amendment NG0262B1 deleted it from the 8703.2 subheading text (rotary-piston cars now classify there), confirmed against the UK Trade Tariff and US HTS; the upstream Comtrade abbreviation is itself only partially updated, and the WCO legal text wins. Both descriptions corrected, the verification pin and provenance re-audited. Third: the API docs advertise the response-envelope JSON Schemas at /schemas/response-envelope.v1.json and v1.1 — both URLs returned 404 because the files were never published; they are now served, with a build gate pinning the published copies byte-identical to the source of truth.
The website API grew faster than the workflow platforms that wrap it; today they catch up. Zapier 0.5.0 adds six operations — Calculate Freight Emissions, Validate Freight Identifier, Check ICS2 Goods Description, Find Airport, Find Nearest Airport, and Resolve Freight Identifier — bringing it to 26 operations (15 Creates, 11 Searches). Just as important is what the update removes: every declared output field is now verified against a live production response, so 17 field declarations that production never actually returned are corrected or deleted, and the ADR operations declare the new Table A scope verdicts (not_subject_to_adr, conditions_ref, carriage_prohibited) so a Zap can branch on them. n8n 0.4.0 adds the resolve operation and repairs the ADR DG-validation template to read scope verdicts BEFORE doing any 1.1.3.6 arithmetic — a carriage-prohibited line now dominates the workflow verdict instead of scoring zero points and passing, and an all-not-subject consignment no longer claims vehicle marking and driver certification are required. The Make app gains a Resolve Freight Identifier module and the same scope fields. Identifier spellings differ per platform where a platform forces it; the JSON contract underneath is one and the same.
Born from a real routing miss: the three dangerous-goods tools answer three different questions, and a user landing on the wrong one had no signpost — a correct answer to the wrong question reads like a wrong answer. Every DG tool page — the UN number lookup, the 1.1.3.6 exemption calculator, and the LQ/EQ checker — now opens with a dashed “use this when” card naming all three siblings: the lookup is for what a substance IS (classification, packing group, tunnel code, LQ/EQ values), the calculator is for whether a LOAD’s total quantity stays under the 1,000-point small-load threshold, and the checker is for whether small inner packagings qualify for Limited or Excepted Quantity relief. The current page is marked, the other two are one click away, and the copy lives in one shared component so the guidance cannot drift between pages.
An agent holding an opaque string — “176”, “UN1845”, “NLRTM”, “FOB”, “22G1”, “MSKU1100810”, “D/E” — previously had to know which of two dozen tools fits before it could ask. GET /api/resolve and the resolve_reference MCP tool are the front door: one call, thirteen identifier grammars ALL run (UN numbers, AWB prefixes, airline and airport IATA/ICAO, UN/LOCODE, ISO 6346 container numbers with the check digit computed, HS codes, Incoterms, ADR tunnel codes, ULD serials, ISO container size/type codes), and every match comes back as a typed candidate carrying the core record, a one-line summary, a per-candidate dataset citation, canonical_url and api_url for depth, and a normalized verification_status with its basis stated — record-level provenance where the dataset has it, the dataset-level claim where it does not. AMBIGUITY IS THE PRODUCT: “LHR” returns Heathrow AND the Egyptian carrier that genuinely holds ICAO designator LHR, ranked — never a silent guess — and every candidate carries rank_basis, so the deterministic ordering (record hit, then verification status, then a static dataset prior, then a stable tiebreak) is inspectable rather than asserted. A container number whose check digit fails is returned with valid: false instead of being dropped. Zero candidates is a valid answer: count 0 with a note, never an error, never an invention. Honesty notes worth stating: 8–10 digit national tariff lines resolve at their 6-digit international parent and say so (the dataset is the international nomenclature); an ISO container type that matches the pattern but is not among the 10 served types returns a pattern-only candidate with no record; SCAC/BIC codes are not covered yet. The endpoint is the first to serve response envelope v1.1 NATIVELY — always enveloped, ?envelope=1 for byte-stable v1 — and v1 is deliberately single-token: paste-a-manifest extraction is a later revision, not scope creep. A golden test set from the design spec gates the build: the ambiguity quartet, every grammar, the check-digit fail, garbage in, normalization variants, and the ranking invariants.
A certified ADR planner asked this site whether dry ice needed ADR paperwork and walked away believing it did. The data was right — UN 1845 is listed “NOT SUBJECT TO ADR except for 5.5.3” in ADR 2025 Table A — but the phrase lived in the packing-group field, the calculator ran the 1.1.3.6 small-load arithmetic over a substance that is outside ADR scope, returned “1.1.3.6 exemption applies” (right boolean, wrong law), and never mentioned the things section 5.5.3 actually requires of the planner. That is fixed at the modeling layer, not the copy layer. 28 Table A rows carry a row-level verdict remark instead of a packing group — 14 rows say “NOT SUBJECT TO ADR” (one of them, dry ice, “except for 5.5.3”) and 14 rows say “CARRIAGE PROHIBITED” — and every one is now a proper flag: not_subject_to_adr with a conditions_ref, or carriage_prohibited, with packing_group null and the original cell preserved verbatim in table_a_remark, re-verified row by row against the purchased UNECE ADR 2025 Table A Excel. Out-of-scope rows now SHORT-CIRCUIT every verdict surface. The exemption calculator returns a dedicated verdict — “Not subject to ADR (road). Section 5.5.3 applies: …” — with the 5.5.3 requirements quoted VERBATIM from the purchased ADR 2025 text and cited to subsection: ventilation with the authority’s own definition of “well ventilated” (CO2 below 0.5 % by volume), the “CARBON DIOXIDE, SOLID” package mark, the 150 × 250 mm asphyxiation WARNING mark, the transport-document line with the authority’s worked example, unpackaged-dry-ice separation, and training — and it never runs the points math. The LQ/EQ checker answers not_applicable instead of the old “limited quantities not permitted for this substance”, which had framed dry ice exactly like a genuinely LQ-prohibited explosive. THE CARRIAGE-PROHIBITED HALF OF THE BUG WAS WORSE THAN THE DRY-ICE HALF: an entry ADR bans from the road entirely — nitrogen trioxide, methyl nitrite, fourteen of them — came back “1.1.3.6 exemption applies, exempt: true” from the same null-transport-category fall-through. Those now return exempt: false with “Carriage prohibited by ADR (road)”. Mixed loads stay honest too: dry ice riding with petrol is excluded from the points and the exclusion is stated in warnings rather than silently zeroed. The REST responses, hosted MCP tools, calculator UI, UN pages, search results, envelope citations, OpenAPI spec and the downloadable n8n workflow all say the same thing from one shared module.
Every UN entry page and every exemption-calculator result now shows the WHY, not just the verdict. The threshold panel — a dashed informational card, one per ADR Table A row — states the transport category and what it means in points, how much of that substance alone stays under the 1,000-point threshold, the per-substance maximum from 1.1.3.6.3, the limited-quantity limit with its chapter, the excepted-quantity code expanded into its actual inner/outer limits, the tunnel code with a link to its meaning, and the special-provision pathway where one applies. In the calculator the panel goes further and shows the live arithmetic per line — quantity × multiplier = points, counted against the threshold — so the number in the verdict is checkable at a glance instead of asserted. A planner’s decision fields — is it in scope, which category, how much before the exemption dies, can it ride LQ or EQ, which tunnels — now sit on one card instead of scattered across a lookup, a calculator and the book.
Every airline record can now carry a status: active_verified, historical, unverified, or nothing at all. Nothing at all means never audited, and it stays absent rather than defaulting to false, because a recorded false asserts a check that never happened. Wave 1 worked the 391-carrier AWB/cargo subset — the records freight desks actually look up — against pages the carriers publish themselves, with FAA Order JO 7340.2P Change 3 for ICAO designators and callsigns and IATA’s free code search only as a tie-break. Aggregators and directories score zero: if a number is only findable on airhex or airlinecodes.info, this dataset now treats it as unevidenced. THE HEADLINE IS THE HONEST ONE: 81 RECORDS ARE VERIFIED, AND 5,961 HAVE NEVER BEEN CHECKED. 98 carriers are confirmed ceased and 217 were audited without clearing the bar. 183 of the 399 records worked in this wave — 45% — ended as leave_unverified, and the commonest single reason is worth stating plainly: THE CARRIER DOES NOT PUBLISH ITS OWN AWB PREFIX ANYWHERE CITABLE. SriLankan 603, TAAG 118, Ethiopian 071, Finnair 105, Suparna 871 — all live carriers with confirmed IATA and ICAO identity, and no page of their own that states the prefix. Aggregators have those numbers. We are not counting them any more. Every finding that would change what this site publishes was then attacked by a second pass whose job was to refute it, and 29 OF 212 DID NOT SURVIVE. Two failure modes recurred. The first is the group-site trap: a parent cargo brand lists prefixes without saying which belongs to which member, and that is not evidence for one member. It took down British Airways 125 and Iberia 075 (neither cited IAG Cargo page contains either airline’s name — the page credits both prefixes to "the airline (IAG Cargo)"), Air France 057, KLM 074, Cargolux 172, Qantas-group 081 on QantasLink and Sunstate, and the Air Arabia prefix set. EMIRATES 176 FELL TOO: the only prefix examples labelled as such in Emirates’ own SkyCargo PDF are 888-…, twice. The second mode is cessation resting on a zero-scoring source plus absence from the FAA list, which is negative evidence — city-connexion is the clearest case, since the current FAA list still assigns CIX to it. Where the refutation was about the source rather than the fact, the second pass went and found qualifying evidence: Fly Asian Xpress’s own archived cessation notice, easyJet’s own GB Airways acquisition release, ENAC’s AOC revocation for Livingston, Zefix’s liquidation record for JetClub. TEN VALUES CHANGED, EACH ON CARRIER-PUBLISHED EVIDENCE. Air Arabia Abu Dhabi’s AWB prefix was wrong — 360 corrected to 049, proven twice on the carrier’s own rate sheet and tracking endpoint. Jeju Air 575 → 806, Starlux 693 → 189, Mercury World Cargo gains 805 ("its own air waybill (prefix 805)" on the carrier’s own site), Air Inuit’s callsign AIR INUIT → INUIT, and Georgian Airways’ IATA code was TGZ — a three-letter ICAO-shaped value sitting in the two-letter IATA field — corrected to A9. Three records that never operated aircraft (two duplicate Amadeus GDS rows and a GSA/broker) lost their cargo-carrier flag. A VERIFIED RECORD NOW HAS TO BE EVIDENCED TO SAY SO. The 36 records carried over from the May anchor pass cited Wikipedia and airhex alongside the carrier page and had per-field sources for the ICAO code and callsign only — nothing backing the IATA code or the AWB prefix, which are the two fields those pages are about. They would have emitted high confidence and a "verified against carrier-published pages" claim. 25 were re-sourced properly by this wave; the other 14 were demoted to unverified with the reason recorded on the record. No active_verified record in the dataset now cites an aggregator, and every one of them carries a citation for every identifier it serves — a build gate enforces both, in both directions, so the status cannot be inherited again. The API reflects all of it. /api/airlines gained status, status_checked_at, data_note and field_provenance (which page confirms which field), and the answer’s confidence now derives from the records in it: an answer made entirely of verified records is emitted at high with no advisory, and any other mix — one unverified record, one never-audited record, an empty result — stays at medium with the PROVENANCE_PENDING warning. verified survives as a deprecated computed mirror for one release; it was also wrongly declared REQUIRED in the OpenAPI spec while being absent on never-audited records, and that is fixed. 1,450 per-field citations across 392 records, 100 distinct publishers behind the verified set.
/airlines/iata/{code} and /airlines/awb/{prefix} are the canonical airline pages: 321 IATA pages and 355 AWB-prefix pages, each about the identifier and listing every record that holds it, with each holder’s own verification status. Shared and reassigned identifiers are the point rather than an embarrassment — an IATA code that three records claim is exactly what somebody typing that code needs disambiguated. The old /airlines/{name} URLs permanently redirect to the matching identifier page (all 399 verified to resolve, no dead ends) and the sitemap lists only the canonicals. Callsigns are searchable on the browse page for the first time — "speedbird" and "dynasty" previously matched nothing. The browse page also now states its verification coverage up front, deriving every figure from the dataset at build time rather than a typed number.
If you need more than the Pro plan’s 50,000 requests a month, SLA terms, or invoicing instead of card payment, /pricing now says who to talk to. It is deliberately an invitation and not an offer: there is no SLA, no uptime credit and no invoicing today, so the copy promises a straight answer about what can actually be committed to, and nothing else.
Response envelope v1.1 ships, opt-in via ?envelope=1.1. On every dataset that has editions — ADR, HS, UN/LOCODE, Incoterms, ICS2, emissions, container validation — the _source block gains three fields: dataset_edition (what this site serves), authority_current_edition (what the authority currently publishes), and checked_at (when that was last confirmed against the authority itself). When the two editions ever differ, the envelope injects an EDITION_LAG warning automatically — the lag becomes a machine-readable statement in the response rather than a footnote in our docs. AT LAUNCH IT FIRES ON NOTHING: all seven editioned datasets were re-verified current against their authorities the day this shipped, and that was a gate — v1.1 was not allowed to ship advertising a lag. The check is now standing infrastructure: an authority-edition registry records how each authority is checked and how often that check may go stale before the build fails, with the staleness window justified per authority cadence (ICS2 moves weekly; Incoterms changes roughly once a decade). Datasets without editions — airports, UK duty rates — deliberately omit the fields, because an edition claim on continuously-maintained data would be false precision. V1 IS UNTOUCHED, BYTE-STABLE, AND STAYS THE DEFAULT: deployed integrations pin envelope version "1" strictly, so the version is negotiated per-request by query parameter only. The hosted MCP endpoint serves v1.1 to all MCP clients (the schema and response update atomically there), and the npm package requests v1.1 from release 2.15.0 — existing installs keep working unchanged.
The French emission factors moved from Base Carbone V23.6 to the authority’s current v23.11, fetched from ADEME’s live Base Empreinte channel (the open-data mirror this site previously cited is frozen at V23.6 — citing it would have reported "no change" forever, so the citation moved to the live channel). The export’s SHA-256 is recorded in the provenance notes, every ingested row was checked against the provider column — all ADEME, none ecoinvent, which is a binding constraint of the individual registration declared to ADEME — and ADEME’s own FAQ permits exactly this use: making the datasets available in a tool for third-party use. Six of the seven French factors are byte-identical in v23.11; their editions and verification dates now say so. THE SEVENTH IS RETIRED, AND THE REASON IS WORTH STATING PLAINLY. This site has said, on every emissions surface, that GLEC Framework values are never stored or returned — GLEC is cited as methodology only. The v23.11 re-verification revealed that the ADEME sea-container factor was GLEC-derived all along: the authority’s own V23.6 row sources it to "GLEC Framework — Février 2020", and in v23.11 the element became per-TEU-kilometre GLEC-2025 data — the wrong unit for this calculator on top of the prohibited source. Every valid container element in v23.11 is GLEC-sourced, so there is no compliant replacement: the factor is withdrawn rather than substituted. French sea-container requests now return the calculator’s honest not-available answer listing the covered alternatives, and the no-GLEC-values claim is now true of every factor served. The remaining expired-validity factor (inland waterway) is ADEME-original and keeps its value; its edition string now states the authority’s own position — the validity period ended in 2021, the element remains marked valid in v23.11 — instead of hiding it.
Search data showed practitioners asking for the ADR tunnel restriction code table — often in Dutch and German spellings — landing on our guide at position twenty-something and not clicking. The page assumed the problem was understanding; the queries say it is lookup. It now leads with the answer: an atomic definition of what a restriction code is, then the full restriction-code table — every code that appears in the served ADR 2025 dataset, including the two Class 1 quantity-qualified codes the old prose list omitted, with the tank versus package prohibition and what each code means for routing. Per-code counts of UN numbers derive from the dataset at build time rather than being typed. The source line states the edition and verification status the way every other ADR surface does, and a glossary line names the Dutch and German terms for the same column — glossary, not keyword bait. Entries with no tunnel code at all are explained rather than glossed over: they are the rows Table A assigns none, because the goods are either not subject to ADR or not accepted for carriage. Every UN number page now links to the table from its tunnel-code row, and structured data (DefinedTermSet) describes each code for machines.
A new quick-reference chart at /incoterms/chart puts the whole Incoterms 2020 rule set into a single matrix: where risk transfers, where cost transfers, who insures, who clears export and who clears import — split into the any-mode rules and the sea-and-inland-waterway rules, each rule linking to its full page. The facts come from the same verified dataset the lookup and API serve; the wording is FreightUtils’ own summary, because the ICC’s rule text is copyrighted and this site does not reproduce it — the chart carries the trademark attribution and points to the ICC publication for the authoritative text. Print-friendly by design: the chrome disappears and the matrix fits a sheet. Answer-first like the tunnel-code page: the atomic answer (how many rules, the two families, the two asymmetries — only CIP and CIF oblige insurance, only DDP puts import clearance on the seller, only EXW puts export clearance on the buyer) sits above the fold with the source and verification status.
The emissions calculator’s four US EPA factors were sourced from the EPA GHG Emission Factors Hub 2024. The Hub’s 2025 edition (published January 2025) revised the freight ton-mile factors, and the correction is material: TRUCK MOVES FROM 0.11597 TO 0.12841 kgCO2e/tonne-km (+10.7%), AIR FREIGHT FROM 0.62481 TO 0.74991 (+20.0%), waterborne −6.2%, rail −4.1%. Every US-region answer this calculator gave carried the older values — we were 10–20% low on US truck and air, and saying so plainly is the point of logging corrections publicly. The UK (DEFRA) and France/EU (ADEME) factors were unaffected, so only region: us results move. The re-ingest reads the authority’s own 2025 xlsx (recorded with its SHA-256), keeps the same AR5 GWP basis the 2025 edition re-confirms, and documents a precision change honestly: the 2025 file stores the factors at published precision rather than the unrounded intermediates the 2024 file carried, and our derivation now records the authority’s cells verbatim. In the same pass the UK DEFRA leg was re-proven rather than assumed: DESNZ republished the 2026 flat file on 31 July 2026 with corrections scoped to non-freight WTT rows, and all eleven DEFRA factors we serve were re-diffed against the revised file — byte-identical, tank-to-wheel and well-to-wheel both, including the WTT arithmetic. Zero impact, verified rather than expected. One lag remains open and is stated rather than hidden: the ADEME component serves Base Carbone V23.6 while the authority’s live channel is at v23.11, whose change notes revised the fuel-upstream component all our ADEME factors include. The open-data mirror we originally cited is itself frozen at V23.6 — re-reading it would falsely report "no change" — and the live export sits behind an account. The refresh is queued; until it lands, the provenance notes on every emissions response name the lag.
The dataset now ships UNECE’s current 2025-1 edition — 116,232 locations, up from the 2024-2 edition this site had served since launch. The files came from the authority’s own release artifact (the official vocab-locode repository’s 2025-1 release job), fetched on 2026-08-10 and recorded with a SHA-256 in the provenance notes, and the ingest tool is now edition-parameterised so the next edition cannot require code changes to ship. THE LARGER CHANGE IS WHAT THE RE-INGEST REFUSED TO SERVE. Every coordinate now passes through the strict validator the 2026-08-03 audit built — the one that established that UAMIR, UAMYR and ZAUHL carry impossible longitudes in UNECE’s own published strings. Applied to the whole file rather than the three audited cases, it found the same defect at scale: 274 coordinates whose published minutes exceed 59 — "4162N" is not a coordinate, but the previous parser had no minutes check and served it as a confident 42.03° — plus the audited three and the two explicit "0000N 00000E" placeholders. All 279 now return a coordinatesBasis naming exactly why the coordinate is withheld, instead of a number that was never true. Nothing was invented and nothing silently dropped: the basis vocabulary is the audited one (withheld-source-out-of-range, withheld-source-placeholder), and the five locations read live from the UNECE directory on 2026-08-03 publish byte-identical strings in 2025-1, so their audit rationale stands unchanged. Every surface stating the edition moved together: the /unlocode page, the response envelope citations, the provenance constants, the API docs, and the unlocode_lookup MCP tool (freightutils-mcp 2.14.3, republished). The superseded edition and count strings are registered so they cannot silently return.
FreightUtils stopped calling itself ground truth. The homepage section heading, the API-docs overview and the MCP page all made that claim about this site, and this site cannot yet mechanically guarantee it — datasets still carry pending-verification states, and one is an edition behind its authority. The wording now says what is actually true: SOURCE-BACKED FREIGHT FACTS YOUR AGENTS CAN INSPECT AND CITE. The claim moves to where it belongs — on the named sources — and the envelope is the proof, not the adjective. The same pass retired "authoritative" from every description of our own data: the site-wide structured data, the API-docs intro and its structured data, and llms.txt now say "source-backed" and "from named primary sources". Where a page said every answer carries a verified date, it now promises exactly what every response actually delivers: authority, edition, and a verification status an agent can read. Disclaimers that point AWAY from FreightUtils — "not authoritative for navigation", "consult the original publication" — are the honest half of the story and did not move. Five calculator badges said "Real-time"; they meant the result recomputes as you type, which a reader could take as a data-freshness claim — they now say "Instant", which is the thing they meant. The airline page got the same honesty: its metadata called the AWB prefixes "verified" while the dataset’s verification flags are still being reconstructed (the claim is gone); the table now renders three explicit states — verified, unverified, and unchecked — where never-checked records used to display indistinguishably from verified ones; and the CSV export stopped writing "false" for records nobody ever checked, because never-checked is a different statement from checked-and-failed, and asserting an audit that did not happen is exactly the class of claim this release removes.
Every keyed API call now increments a per-key, per-endpoint daily counter, kept for 90 days and then expired by the store itself. It exists so two questions become answerable at a glance — which endpoints is a given key actually using, and when was it last seen — first on the owner’s side, and next as each account’s own per-endpoint usage view, which needs history to exist before it can show anything. WHAT IS STORED IS THE KEY, A COARSE ENDPOINT LABEL, THE UTC DATE, AND A NUMBER. No request content, no parameters, no IP, no email. The endpoint label is selected from the same closed vocabulary the existing tier counters use, so nothing derived from request text can mint a new counter — the property the wrapper-telemetry release established, kept deliberately. The retention figure is a property of the storage rather than a promise about a cleanup script: the expiry is set at first write and re-armed while the key stays active that day, so a dropped housekeeping call cannot quietly turn 90 days into forever. The privacy policy gained the matching line in the same deploy — per-key, per-endpoint call counts, retained 90 days, used for quota and service monitoring — because a counter that touches identity, however lightly, should be disclosed the day it starts accruing rather than found later.
Four integrations publish FreightUtils — the Zapier app, the n8n node, the Make app and the npm stdio MCP server — and until now NONE of them sent a distinguishing User-Agent. Every call they made arrived looking like any other server-side HTTP client, so the server could not attribute a single request to any one of them. The only signals available were the platforms’ own dashboards and npm download counts, and downloads measure INSTALLS rather than calls: a package baked into someone’s Docker image downloads on every build and may never make a request. Each wrapper now sends User-Agent: package-name/version, read from its own package.json so it moves with each release instead of being typed in once and going stale. The server maps those prefixes to a per-surface counter and the owner-only metrics view shows the split. WHAT IS STORED IS A LABEL AND A DATE, NOTHING ELSE — no IP, no API key, no account identifier, no request content, the same metadata-only rule the existing counters follow, and the privacy assertions in the metrics gate were extended to cover the new dimension rather than left applying only to the old ones. The surface label comes from a CLOSED list, which is a security property rather than tidiness: the label is written into a storage key, so if it were derived from the User-Agent text then any caller could create unlimited keys just by varying its own header — enough noise to bury the real numbers. The header only ever SELECTS from a fixed set of five known values, and anything unrecognised is counted as nothing at all. Zapier’s own platform User-Agent is tracked as a separate label from our Zapier app, deliberately: they mean different things — "our app called" versus "Zapier’s infrastructure called and our header did not survive" — and folding them together would hide a broken integration behind a healthy total. Released alongside: freightutils-mcp 2.14.1, n8n-nodes-freightutils 0.3.2, zapier-freightutils 0.4.2, with no tool, schema, field or response changes on any of them.
The n8n node’s operation descriptions advertised "15 ULD types" against an actual 16, and "6,940 HS codes" against an actual 6,937 — the HS figure predates the July verification that removed three non-HS artifacts. A third, in its README, was created by the node’s own previous release: it added an operation and left "19 operations" beside a list of 20. All three were re-derived from the live API rather than corrected from memory, and the vehicle count beside them was checked the same way, found correct, and left alone. Separately, the Zapier app gained a check that drives every one of its operations against production and asserts that each field it ADVERTISES actually comes back. Zapier renders those declarations in the step mapper, so a field that is declared but never returned is offered to users as something they can map and then produces an empty value forever. One such field was found and fixed by hand two days ago; the check found SIXTEEN MORE, across thirteen of the twenty operations, mostly renames the declarations never followed. Each is recorded with its real name and is being fixed as its own change, because correcting a declared output key alters what existing users can map. The check runs the operations’ real code rather than diffing raw HTTP, because several of them reshape the response — a raw diff would report false failures on all of those and get switched off within a week.
The served change first. The double-deck trailer published a rear door opening of 2,480 x 2,700 mm against its own internal height of 3,033 mm. Read literally, the record described a trailer whose top deck cannot pass through its own back door. It was not a claim anybody made: it is the single-deck door figure left behind when the interior height was corrected. It is now NOT PUBLISHED rather than replaced with a better guess, because no cited page supports one. All 32 URLs this dataset cites were read in the previous pass, and rear door width and height are backed by none of them; the double-deck manufacturer FAQ that does back this record’s interior height publishes heights only, and no page read publishes a rear aperture width for any vehicle at all. This release opened no new sourcing, so there was nothing to correct it to, and replacing a contradiction with a confident number would have turned a visible defect into an invisible one. door_dimensions is null on that one record and carries a new door_dimensions_basis of "not-published" alongside it. That basis is deliberately a stronger statement than "unverified": unverified means we hold a figure nobody has checked, not-published means we looked and no publisher states one. The vehicles page used to DROP the Door Opening row when there was nothing to show, which left a reader unable to tell "this vehicle has no published door figure" from "the site is not showing it"; it now states the reason, exactly as the internal-height row already did. Every other record keeps its door dimensions and gains no new field.
The ldm_calculator preset rigid10 asserts a 10 m rigid body. The verdict was returned in the previous release: no manufacturer publishes such a vehicle, and the figure traces to a trade page describing a 26-tonne body as "around the 30ft (10m) mark", where 30 ft is 9.14 m. The payload half is real; the length is not. NOTHING IS REMOVED AND NO FIGURE CHANGES. rigid10 returns exactly what it returned yesterday, same length, same payload, same pallet count, and will keep doing so until the next major version. Removing a published enum value breaks whoever is pinned to it, so the value stays and the advisory does the work: every response using it now carries a warnings entry beginning DEPRECATED: vehicle="rigid10", naming the removal release and the migration. It is marked deprecated in all six places the enum is published, and removed from the vehicle picker on the loading-metres page so nobody new adopts it. Anyone following a saved link with vehicle=rigid10 still gets an answer, with the preset shown as RETIRED and the advisory printed under the picker. Migrating: use vehicle=custom with a vehicle length, or vehicle=artic. No replacement preset is offered, on purpose. The canonical 9.1 m rigid records remain readable through the vehicle lookup, but their envelope is itself still unsourced, and promoting an unsourced envelope to replace a preset being retired FOR BEING UNSOURCED would repeat the fault rather than fix it. rigid75 keeps its name, and that is now a decision rather than a deferral. Its id numeral drops the decimal point of 7.5 tonnes, and it was a rename candidate only because it shared a prefix with rigid10, which encoded METRES: one prefix, two units. Retiring rigid10 dissolves that. What remains is cosmetic, because no served number is wrong, no two tools disagree, and a 75-tonne rigid is not a vehicle, so there is no competing reading available. A rename would cost a second deprecation window for pinned clients to buy nothing. The API spec also gains a correction unrelated to the above: it described the ldm_calculator warnings array as an array of strings, and the endpoint has always returned objects with a type and a message. The spec now says what the endpoint does.
The Calculate Consignment action’s output list offered suggested_vehicle, a top-level billing_basis, and separate chargeable_weight_air, _road and _sea totals. Zapier renders that list in the step mapper, so all five were offered as things a user could map, and anyone who mapped one has had a step producing an empty value on every run since. Checked against the live API rather than against the code: the consignment engine emits no vehicle suggestion at all (a different tool does that); billing_basis is real but sits under totals; and the response carries ONE chargeable weight, chargeable_weight_kg, computed for the mode requested, not a per-mode trio. The sample also named totals.pallet_spaces and totals.item_count, where the real fields are line_count and piece_count. Every field that was genuinely there keeps its exact key, so no working mapping breaks, and the real-but-undeclared fields are now listed. The sample is a copy of an actual response instead of a hand-written illustration of what the action ought to return, which is how the drift got in.
Asking the vehicle endpoint for a slug that does not exist returned a recovery hint suggesting the slug artic-13-6. No record has ever had that slug, so following the advice produced a second 404. It now points at a real record, and a check refuses the build if any recovery hint ever again names a vehicle that does not exist. This is the one code path a caller only reaches when it is already lost, which is exactly where a wrong answer costs the most.
The methodology table’s vehicle row listed its sources by hand, and the hand-written list had drifted from the data in both directions at once. It named four sources that are not cited: a 49 CFR definitions section — retired as a citation on 2026-08-03 precisely because it sets no dimension or weight limit — plus three vehicle manufacturers that appear nowhere among the dataset’s cited publishers, one of them cited only via Wikipedia, which is a tertiary source and, on reading, backs no stored value at all. And it omitted publishers that DO back served figures: Iveco, whose line-up table is the source for three rigid gross weights; Schwarzmüller and Don-Bur, which back trailer dimensions and the pallet counts respectively; Stoughton; and TIMOCOM, which alone publishes the 13.6 m internal length exactly. The audit date was nearly three months stale — 16 May against a newest source read of 6 August, when all 32 URLs the dataset cites were opened for the containment pass. The row is now derived from the dataset rather than recalled, and the superseded strings are registered so the old names cannot quietly return. Nothing about the 17 records themselves changed here; this is the page describing them catching up.
Lead with the served change. Six articulated records stored a gross vehicle weight of 44,000 kg alongside an axle configuration of 2+3. 2+3 is five axles, and no source puts 44,000 kg on a five-axle articulated combination. The affected records are the 13.6 m curtainsider, the mega/jumbo, the box trailer, the refrigerated trailer, the double-deck and the flatbed — the standard UK and EU fleet. A count in our own notes was wrong too, and it was written into the data it described: both the working brief and the dataset’s own citation-replacement prose said “the five artic records”, and counted against the file it is six. What the sources say, and they agree: GOV.UK’s “HGV maximum weights” (DVSA/DfT) indexes its table by vehicle type AND axle count, and under the Authorised Weight Regulations 44,000 kg sits on the SIX-axle articulated row, with its footnote stating the split in terms — “Each part of the combination must have 3 axles and the trailer must have road friendly suspension”, i.e. 3+3. The five-axle row is 40,000 kg. The 44,000 kg that does appear in the Construction & Use five-axle cell carries a different footnote — “Operation at over 38000kg under the C&U regulations is restricted to combined transport operations” — so it is not a general allowance, and the same page forbids mixing regimes: “vehicles may only comply with one of the regulations - individual parts of the 2 regulations cannot be combined.” Directive 96/53/EC Annex I agrees: clause 2.2.2(a) puts a “two-axle motor vehicle with three-axle semi-trailer” — the 2+3 case, spelled out — at 40 tonnes, and its 44-tonne clause 2.2.2(c) requires a THREE-axle motor vehicle carrying containers “in intermodal transport operations, up to a total maximum length of 45 feet”, so 44 tonnes cannot reach a 2+3 at all. Which field was wrong was decided per record, not by default, and the evidence was internal: each of these records’ own rationale already read “max GVW 44t for 6-axle … artic on the UK derogation”. The rationale said six axles while the field said five, so the record always described a six-axle vehicle and the axle string was mis-entered. axleConfig becomes 3+3 on all six and grossVehicleWeight does not move; dropping the weight to 40,000 kg would have turned these into different vehicles. Two phrases this project has used are also corrected. The consolidated Directive does NOT say “40-foot ISO container in combined transport” — the strings “40 feet”, “40-foot” and “ISO container” appear nowhere in it, and the wording is “up to a total maximum length of 45 feet”. And legislation.gov.uk/uksi/2003/1998, cited on the low-loader, is SI 2003 No. 1998, the Road Vehicles (Authorisation of Special Types) (General) Order 2003 — the “1998” is the instrument’s number, not its year, and the Authorised Weight Regulations are SI 1998/3111. Both are the kind of error that survives every link check ever written.
All 32 URLs cited by the dataset were read. Each of the 150 citations now carries a machine-derived list of the fields the page actually publishes and agrees with us on, plus how it was retrieved: 119 carry a field-level claim and 31 back nothing. The 31 are the finding, not the leftovers. Every one of those pages returns HTTP 200 — link checking has always reported this dataset as perfectly healthy, and it cannot see the actual problem, which is a citation that resolves and does not contain the value attached to it. So each empty claim now carries a reason on the wire, and the reasons are specific. Directive 96/53/EC was cited on ten records for internal dimensions it does not publish: its Annex I gives overall vehicle envelope maxima (16.50 m, 2.55 m, 4.00 m), a different quantity from the inside of a cargo body. The three US Code of Federal Regulations sections are the same shape — § 658.15’s “102 inches” is an EXTERNAL vehicle width limit, and 2,591 mm, the “internal width” on both US trailers, IS 102.0 inches. A DAF page cited on four rigid records turned out to be a PDF-link index with no specification printed on it at all, whose own model list runs 8 t to 19 t and therefore excludes three of the four classes citing it. A Faymonville citation points at the company homepage. Two Wikipedia citations are tertiary and describe the wrong market. Nothing was deleted: a page that backs nothing keeps its citation and gains a stated reason, because dropping a source is a separate decision from recording that it does not support the field. Two sources were added because they publish what nothing else did. Don-Bur’s pallet FAQ is the only page found that gives a pallet count WITH its loading orientation AND the pallet dimensions it refers to — “26 UK pallets (2 across x 13 down)”, “UK pallet: 1200mm x 1000mm” — and it tabulates the double-deck case at 52/66, matching the double-deck record exactly. TIMOCOM, already cited for the loading-metre divisor, turns out to publish a vehicle table backing four more fields, including the only source found that states 13.6 m exactly (Schmitz, Krone and Schwarzmüller all publish 13.62 m; Don-Bur 13.7 m). The claims are computed, not typed: the evidence — what each page publishes — lives in a fixture, and a build gate re-derives every claim by comparing that against the stored value and fails if the committed file differs. A range that CONTAINS our number never counts as agreeing with it, and an external maximum never counts as an internal dimension. Change a stored value and support recomputes — correcting the axle configuration above is what turned the GOV.UK citation from backing nothing into backing 44,000 kg. It had to earn it. Three fields are backed by nothing at all across all 32 pages: payload, rear door dimensions, and axle configuration. Not one page publishes a payload figure matching any stored value, only one publishes a rear aperture height and it disagrees, and none publishes an axle-configuration string. That is now visible instead of assumed.
The 18-tonne and 26-tonne rigids no longer report a pallet count, and that is a capability loss said out loud. All three 9.1 m rigid records claimed 16 Euro and 12 UK pallets, and the figure has no source: bodybuilders, truck manufacturers, rental fleets and the pallet networks all publish body length or payload and stop, while the forwarder pages that do carry counts disagree with each other and with us across bodies of 7.0 to 9.15 m. The stated reason for the number was circular, and the dataset disproved it itself. Two of the three declared the count “weight-limited”, arguing that the deck tiles to 22 Euro positions but payload divided by 22 is too low per pallet, therefore 16. That is inverted — payload over a geometric count is just the average weight you would be carrying, not a constraint that reduces the count. The proof is inside the data: the curtainsider rigid has a LOWER payload than the box rigid and the SAME count of 16, with its own rationale noting the per-pallet figure moving while the count does not. The per-pallet weight was back-solved from an answer fixed in advance. Geometry was available and was deliberately not used: tiling this deck gives 22 Euro and 18 UK, not 16 and 12. Publishing a tiled figure is legitimate when the deck is corroborated — that is exactly how the 13.6 m artic’s 33 Euro stands up, with its 2,480 mm width confirmed by Schmitz Cargobull and 33 independently published by both Don-Bur and TIMOCOM — and the 9.1 m rigid deck has neither leg. Tiling an unsourced deck would stack a computation on an unverified figure and present the result with floor-plan confidence. There is also a circularity risk worth stating: while searching for a publisher of these counts, a search summary returned our own envelope and our own figures — “9.10 x 2.48 x 2.60 m … 16 EUR / 12 UK pallets” — attributable to no page anybody opened, with freightutils.com itself appearing in the result sets. We may be the internet’s source for this number, and citing it back would close the loop. The US counts were the wrong figures, in the permissive direction. A named US carrier publishes three counts for a 53 ft dry van and names the loading pattern for each: 26 loaded straight, 30 turned, 28 pinwheeled. We were serving 30 — the turned figure, with nothing saying it needs every pallet turned. It is now 26, the straight figure and the conservative one, with the other two recorded against the record and their patterns named. The 48 ft trailer’s count is withdrawn entirely: that carrier publishes no 48 ft figure, the only one found anywhere (24) comes from a dealer, and our stored 26 matches nothing published for a 48 ft trailer at all — it looks like the 53 ft figure applied to a shorter trailer.
The rigid10 loading-metre preset claims a 10 m load length and a 12,000 kg payload on a rigid truck. It has been carried as a known open item with the instruction to source it or retire it. It cannot be sourced, and the two halves came apart on reading. The payload is real — a national rental fleet publishes “Est Payload +/- 5%: 12000kg” on a 26-tonne DAF box rigid, verbatim. The length is not, and it is above every manufacturer’s own published ceiling: MAN’s bodybuilder data sheets give a maximum recommended body length of 9,360 mm for the 26-tonne TGM and 9,285 mm for the TGS and TGX, Volvo’s equivalent works out to 8,760 mm, and real published bodies run 8.26 m to 9.36 m. The two figures never appear together on one page for one vehicle, and going up a weight class does not help — the Construction & Use Regulations cap a wheeled motor vehicle at 12 m regardless, so a 32-tonne eight-wheeler buys payload, not length. Where the 10 m came from, because a wrong number with a traceable origin is worth more than a deleted one: a trade classifieds page writes that a 26-tonne curtainsider body is “around the 30ft (10m) mark” — and 30 ft is 9.14 m — while the same site elsewhere derives a “theoretical maximum length for a box body of 10 metres” by subtracting only a 2 m cab from the 12 m limit and forgetting the front overhang. The preset is not removed here: removing a published enum value breaks clients pinned to it, and retirement is an alias-then-deprecate change across six surfaces. The verdict is recorded against the waiver so it stops reading as an open question, and the replacement is already sourced for whoever does it — a 26-tonne 6x2 rigid at 8.5-9.2 m and 12,000-15,500 kg clears a two-independent-source bar several times over.
Lead with the correction, because it lands on yesterday’s correction. The previous entry widened the 300-character GPT Actions gate from paths-only to the whole document and reported 362 descriptions. That number was itself an under-count — the exhaustive reference walk finds 365. The three keys the widened walk still never visited, named rather than counted: ContainerSpec/properties/description/description, HsCode/properties/description/description, and Ics2CheckResponse/properties/description/description. All three are schema properties literally named “description”. The walk was node-driven — “does this object have a description?”, then recurse into every key except description. That exclusion is right when a description’s value is a string and wrong when the value is an object, because the property’s own description then sits one level below a key the walk refuses to enter. A freight API has description fields throughout its request and response bodies, so this is not an exotic shape — it is the shape a schema takes whenever it documents a field called “description”. servers/0/description was NOT one of them: the old walk already reached it, which was verified rather than assumed and negative-tested at that location anyway. The fix is exhaustiveness, not a longer exclusion list. The walk is now key-driven — every string-valued description key anywhere, recursing into description whenever its value is an object. The count moved 362 to 365, which equals the exhaustive reference walk, so there is nothing left to exclude and the success line says so instead of implying it. One walk, not two: the offender list and the printed census were separate traversals that could disagree, which is precisely how a success line ends up describing coverage the check does not have, and it is the mechanism behind both of the last two corrections here. They are the same pass now, so the count and the verdict cannot drift apart. The success line also names where the longest description is rather than only how long it is. Negative-tested at both a genuinely-blind and an already-covered location, because a guard that only passes is a guard nobody has tested. Fourth instance in four days of one shape: a check, or a check’s own account of itself, claiming more coverage than it had. No served value changed — openapi.json is byte-identical. The spec was already compliant at 365 of 365; the gate simply was not in a position to know that.
Every cited source on the vehicle dataset gained a fields_supported list. It is a machine-checkable claim about containment: not “this URL resolves” and not “somebody read it”, but “this page publishes this named field”. The existing supports prose stays as the human rationale alongside it. Every one of those lists is empty today, and that is the honest starting state rather than a default: all 147 citations carry an empty fields_supported. A new build rule validates the shape whenever a list is filled — the field must exist in a declared vocabulary, must name a field the record actually carries, and must NOT name a field whose stored value is null, because a null is not a value anyone can corroborate. That last case was found by negative-testing the new rule and discovering it stayed silent. Empty lists are counted and printed on every run instead of failing the build, because a rule that goes red on work in progress is a rule somebody switches off. This is on the wire, so it is worth being exact about. /api/vehicles passes each record’s source list through by reference, which means every per-source key is served verbatim — already true of accessed_at, replaced_from, replaced_reason and supports, verified by curling production. So fields_supported now appears on /api/vehicles, on MCP vehicle_lookup, and in the /vehicles page payload. No existing value changed on any of the 17 records; a key was added. What was measured on the way in, because the plan for this work was two sprints stale: the brief described 51 sources, 24 distinct URLs, one populated read-timestamp and roughly 28 dead links. Counted against the data instead — 147 citations across 30 URLs, 138 read-timestamps, and zero dead links. All 30 URLs return HTTP 200, probed live with a browser user-agent and redirects followed, including the two the brief named as the worst offenders: the EUR-Lex text of Directive 96/53/EC and the gov.uk vehicle-weights collection. Both had already been repointed by the August re-sourcing pass. Link rot on this dataset was fixed before this lane opened, and re-deriving is the only reason anybody knows that. What is actually left is the harder thing, and it is now visible: 43 of the 147 citations carry no containment claim of any kind, including both regulatory citations that appear on ten records each — exactly the ones suspected of being cited for internal cargo-body dimensions and payload figures they do not publish. A page that returns 200 and does not contain the value attached to it is invisible to every liveness check ever written. Counting it is the first step to closing it.
Yesterday’s fix wired a guard that enforces ChatGPT Actions’ 300-character description limit on the public OpenAPI spec. The guard was walking paths only — its success line said “every description”, and info and components/schemas were never visited. Two descriptions were sitting outside it and over the limit. One was SourceEnvelope at 602 characters — the provenance citation block, and not an orphan: it is referenced by 16 response schemas, so it resolves into the action schema for essentially every operation. The other was the API’s own front-door description at 505. So the previous fix corrected the one description the guard could see, while the larger one reached the import through every response that carries provenance. The walk now covers the whole document — every description key, reached recursively from the root — and the success line reports what it actually checked: 362 descriptions, longest now 298. The claim is true by construction rather than by assertion, which is the entire point. (Corrected 2026-08-06: 362 was itself an under-count and “every description key” was not yet true — the widened walk still refused to recurse into a key named description, so three schema properties literally named description sat below it, unvisited. The exhaustive count is 365. Made key-driven the next day; see the 2026-08-06 entry.) Both descriptions were rewritten rather than truncated. The API description drops the JSON/CORS note (the spec already encodes response types) and now leads with what the layer is. The provenance description drops its field-by-field enumeration, because the schema declares all eleven properties and the prose was restating them — and it drops the phrase “from primary sources”, deliberately: that was a blanket claim the per-response provenance envelope now makes honestly, record by record, and not every pending dataset is primary-sourced. Third instance in three days of the same shape — a guard, or a guard’s own description of itself, claiming more coverage than it had.
GET /api/uld’s description in the public OpenAPI spec was 332 characters. ChatGPT Actions enforces a hard 300-character limit, where a single over-length description silently breaks action loading — so that action may not have been loading in the Custom GPT at all. The description was the symptom. scripts/build-gpt-openapi.mjs already validates descriptions against that limit and fails when one is over (corrected 2026-08-05: this originally read “every description in the spec” — it checked every description under paths only, and two outside that walk were over); its own header calls --check a “CI gate”. It was invoked by no npm script, no workflow and no git hook, so the thing built to prevent exactly this had never run once. The description is now 279 characters and keeps everything that mattered, including the warning that an unrecognised query parameter returns 400 rather than being quietly ignored. Regenerating the spec through the generator produced no other change, which is the useful part: the served spec had not drifted from its own generator despite the generator never having run in a wired path. --check now runs inside npm run lint. And because the guard sat one directory above the folder the wiring check scans, that check gained a curated list of gates living outside it — curated, not a wider scan: 58 of 104 scripts in that folder are legitimately one-shot, and a rule that fires 58 times on day one is a rule somebody switches off. Second instance of this class; the first was the vehicle-truth guard in July. Both are now written up as a fault category of their own, distinct from its sibling — one is a gate whose failure gets swallowed, this is a gate nobody calls.
A consistency guard reported that five files computed loading metres by dividing by 2.40 m while the canonical curtainsider record stored an internal width of 2,480 mm, and called every LDM the site publishes 3.2% wrong. It was not wrong. 2,480 mm is the trailer’s internal shell width; 2.40 m is the divisor the loading-metre convention is defined on — two true quantities that had been sharing one field, and the guard was comparing against the wrong one. Every road LDM and chargeable-weight figure published on web, REST and MCP was correct, and none of them changes. Twenty-three publishers were fetched and read to settle it, and every one that states a divisor states 2.4 m: DHL Freight, Gebrüder Weiss, sennder, Savino Del Bene, DSV and ExFreight on the carrier and forwarder side; Soloplan, IMPARGO, Cargo-Planner, EasyCargo, Goodloading, Routix and Pallet3D among load-planning vendors; TIMOCOM and Trans.eu as freight exchanges. Zero variance on the number, and six were re-read directly rather than taken on report. What decided the field name: not one publisher distinguishes a usable loading width from an internal shell width, and TIMOCOM prints a 2.45 m articulated loading-space width on the same page as its 2.4 m divisor. Soloplan calls the figure “a standardised value in Germany”; Cargo-Planner calls it “a constant”. Naming the new field usableLoadingWidth would have asserted a measured quantity the evidence does not support, so it is ldmDivisorWidth with a basis stating whether it is the convention or the vehicle’s own width. SERVED VALUES THAT DO CHANGE, each with its rule. The ldm_calculator presets held their own figures and 19 disagreed with the canonical record they named; they are now derived from it. The 7.5t Rigid goes 5.2 m to 6.1 m, 3,500 kg to 2,500 kg, and 10/8 pallets to 8/6. The 3.5t Luton Van goes 4.0 m to 4.1 m, 1,200 kg to 1,000 kg, and 6 Euro pallets to 5. The 53ft US trailer’s payload goes 20,400 kg to 22,680 kg and the 48ft goes 20,400 kg to 19,500 kg. Four preset pallet counts — the Luton’s UK figure and both US trailers’ Euro and UK figures — were asserting numbers the dataset does not have, and now say “not published” instead of inventing one. US trailer LDM shifts by 0.039%, because the divisor is the canonical 2.591 m internal width rather than a rounded 2.59 m literal. shipment_summary was suggesting trucks too small for the load: its ladder accepted up to 10 m and 12,000 kg before recommending an “18t Rigid”, a vehicle that is 9.1 m and 10,000 kg. It named one vehicle and measured another, in the permissive direction. Tiers are now the named vehicle’s real figures. Two tools also disagreed about silence, and the safe one won: an item with no stackable flag was treated as double-stacked by shipment_summary and as not stacked by ldm_calculator, so the same load returned different loading metres depending which you asked. They now share ldm_calculator’s documented default, so an unflagged item’s LDM and pallet spaces both double — the conservative direction, because assuming a load stacks when nobody said so under-quotes the space it needs. The default had been set in two places at once: aligning the engine alone changed nothing at the wire, because /api/shipment/summary coerced an absent flag to true before the engine saw it. Post-merge production verification caught that, and absence now reaches the engine as absence. palletSpaces was reporting rows: 16 non-stackable Euro pallets came back as 6 from shipment_summary and 16 from ldm_calculator for the same load. It now holds pallet floor positions, which is what the name always promised, and the row count moved to a new palletRows field rather than being dropped. Road responses also stopped shipping an empty dataVersion under a high-confidence envelope. A standard was being cited that says nothing about trailers. Three places on the loading-metre page credited the 2.4 m figure to EN 283, which is “Swap bodies — Testing” and specifies no trailer width. The citation is removed rather than repointed, because no standards body defines the loading metre at all — an EUR-Lex full-text search returns nothing for “loading metre” or “Lademeter”, and UN/CEFACT Recommendation 20 codifies “linear metre” while pointedly declining to nominate a width. A fourth line claimed the vehicle specifications were verified in May 2026 when every record in that dataset carries verified:false; it now reads from the dataset’s own provenance. The guard is wired into the build at zero unwaived divergences, down from 44, with five residuals printed on every run with an owner rather than hidden in an allowlist — chiefly the rigid10 preset, which asserts a 10 m / 12 t rigid no canonical record backs. It is not silently retired: removing a published enum value would break pinned clients, and inventing a record to match it would be worse than leaving it visible.
Every cited source in the ULD dataset has now been opened and read by a second agent — 122 of 122 record-URL pairings, up from 83. That did not verify anything, and the reason is the interesting part. The bar was counting URLs when it needed to count publishers: it asked “does this record have a confirmed primary source plus an independent corroborator?” by counting credited sources, and never asked whether two of those sources were the same source. ANA Cargo publishes the same pallet table in three documents — a spec page, its PDF twin, and a separate March-2026 dimension guide — and PMC, PLA and PAG each cite all three. Under the old count that reads as three independent carriers agreeing; it is one carrier’s number published three times. RKN cites Envirotainer at three of its own URLs the same way. Across the dataset, 148 citations resolve to 125 distinct publishers — 23 were being counted twice. Two families of forwarders were also republishing one page between them, the same problem wearing different hostnames: dsv.com, en.freja.com, sdklogistics.com and Pengelly’s all publish an identical ULD spec template, same figures and same IATA code lists down to the same preamble sentence, while transpacificvn.com and tracosa.com both republish the Wikipedia ULD table with LD-7 blocks identical to the digit. Where a record cited two members of one family, it was corroborating itself. The fix is a curated map, not a similarity engine: a citation resolves to its real source by three mechanisms and no others — a URL that redirects to a cited URL is that URL, a short list of hosts proven to republish one upstream, and otherwise the hostname. Nothing is fetched at build time and nothing is guessed from resemblance. Two candidates were examined and refused: airbridgecargo.com is not grouped with Wikipedia, because Wikipedia carries no RKN specification at all for it to have copied, and ACL Airshop is not grouped with the forwarder template, because the resemblance was noticed but never actually matched. A wrong entry in that map would suppress real corroboration and make a record permanently unverifiable, so the bar for adding one is deliberately higher than the bar for suspecting one. Reading the last 28 pages made things worse on purpose: tare_weight now has no non-tertiary confirmation on 13 of 16 records, up from 11, because two records’ confirmations were withdrawn when the pages turned out to publish a different figure, a range, or nothing at all for that code. Two of the tare figures are bands rather than single numbers — one carrier publishes 185–380 kg, one manufacturer publishes three figures by aluminium sheet gauge — and a band that contains the stored value never equals it, so no amount of further reading collapses it into agreement. All four “access restricted” claims are now disproven; every one came from an automated probe failing, and every one of those pages loads completely in a browser with full specification data, so no citation on this dataset is marked restricted any more. Twenty-nine citations are flagged as retirement candidates and none were removed: they resolve fine, they simply do not carry the value they are cited for, across 13 records and 18 distinct URLs, and dropping a source is a separate decision from recording that it does not support the field. No record flipped, all 16 remain pending-verification with medium confidence, and no served value changed — asserted field by field against a pre-write snapshot, with the ingest re-run three times to prove it lands byte-identical.
accessed_at was carrying two claims in one field: somebody opened this page, and the page agreed with us. Only a CONFIRMED verdict could stamp, so a citation the second agent genuinely read and found to disagree came out looking identical to one nobody had ever opened. The dataset could not tell “we checked, it contradicts us” apart from “we never looked”, and those are opposite evidentiary states. They are now two fields. accessed_at means the page was read on that date and nothing more, so MISMATCH, BASIS-MISMATCH and NOT-ON-PAGE all carry one; UNREACHABLE and LOST-IN-COMPACTION carry none, because for those nobody can show the page was opened. Agreement moved entirely to verdict. Stamped citations went 74 to 145 of 148, and the three without a stamp are the ones genuinely never opened — the read instant is derived from the verdictAt already recorded against each verdict, so this is read off existing evidence rather than backfilled with invented timestamps. The agreement gate is now field-level, not record-level: a record-level bar asks “did two good sources confirm something here?”, which PGA passes on its dimensions while its tare rests on one secondary source that two carrier pages contradict. Status is one word for a whole record, so a record-level bar lets a well-sourced dimension carry an unsourced weight across the line with it. Every field holding a served value now clears the bar on its own confirmations or the record does not flip; fields with no served value are not gated, because a pallet has no internal dimensions and demanding corroboration for a null makes absence unverifiable by construction. All four access restrictions turned out not to exist: satco-inc.com fell in batch 2, and freight.qantas.com, cargo.china-airlines.com and cathaycargo.com each loaded completely in a browser with full specification data. Every one of those claims originated in an automated probe failing, which is evidence about the probe and not about the page, and no primary-restricted citation remains on this dataset. Batch 1 over-claimed and re-reading is what caught it: it recorded DoKaSch’s AKH page as CONFIRMED including tareWeight, but the page reads “Tare weight from 73 kg” against a served 85 kg — not a confirmation, and “from” marks a build floor rather than a point value. A second agent’s table is still a report. The correction landed by re-running the ingest with batch 3 layered over batch 1, not by hand-editing a verdict. Three citations are also not the independent corroboration they appear to be, recorded rather than removed: en.freja.com’s LD9 page and Pengelly’s LD9 fact sheet are byte-identical down to the preamble and AAP cites both; dokasch.com/en/akh/ 301-redirects to dokasch.com/en/akh-ld3-45w/, which AKH already cites separately; onze.lt and caterpillarcargo.com publish the same republished “AS1825 volume / Boeing volume” table. Counting either pair as two sources would launder one upstream claim into corroboration of itself. Second-agent coverage moved 83 to 94 of 122 pairings, with the outstanding 28 regenerated into a committed file with full URLs. 0 of 16 records flipped, all remain pending-verification with medium confidence, and no served value changed. Twenty-six citations now carrying NOT-ON-PAGE are reported as retirement candidates; none were removed, because dropping a citation is a separate decision from recording that it does not support the field.
The delta from the weights re-sourcing went back to a browser. 83 of its 122 record-URL pairings now carry a second-agent verdict; 39 do not, and are queued in a committed file rather than described in prose. The headline is that verification went backwards, which is what verification is for: citations carrying a timestamped accessed_at fell from 93 to 74. Every one of those 19 was a first-agent read the second agent re-opened and could not confirm — a page that turned out not to list the code, or one whose figure disagreed once somebody read the words around it. A stamp means “content read and value re-confirmed”, so a stamp the second agent cannot reproduce has to come off. No record flipped to verified; all 16 remain pending-verification with medium confidence, and no served value moved. A new verdict exists, because two different failures were wearing one name: BASIS-MISMATCH means the page publishes a perfectly good number for the OTHER quantity — DoKaSch’s bare-unit “from 520 kg” against a field whose declared basis is a carrier system tare including the net. That is not a disagreement, it is a category error, and calling it a MISMATCH would re-create exactly the comparison the basis layer was built to make refusable. PGA’s 565 kg tare has its first real confirmation and two contradictions to go with it: Cathay Cargo — one of three carrier pages an automated probe could never reach — was read in a browser and confirms 565 kg with the basis stated on the page (“nets attached”), while Qantas publishes 535 kg and China Airlines 505–545 kg, both the same carrier-system basis, both disagreeing. The served figure is now the top of a read, sourced carrier band rather than a number resting on pages nobody had opened; it is unchanged at 565, the conservative choice within that basis. One access restriction turned out not to exist: satco-inc.com was cited primary-restricted because an automated probe could not reach it, and it loads completely in a real browser with full spec data, so that citation is void. Finally, the vehicle citations have a worse problem than link rot, flagged rather than fixed here: gov.uk/hgv-maximum-weights carries generic axle-count weight tables and no internal dimensions, no payload and no vehicle-type names, yet it is cited on ten vehicle records for exactly those figures; Directive 96/53/EC gives external envelope maxima, not internal cargo-body dimensions per model; the DAF page is a PDF-link index. All resolve; none contain what they are cited for.
The bar that decides whether a dataset may call itself verified took a list of source classes and nothing else. A class is derived from the hostname: the classifier looks at the domain and decides whether the publisher is a manufacturer, a carrier, a trade body or an aggregator. It says who publishes the page. It cannot say whether anyone opened it. So a citation could clear the bar on a hostname, and that was not hypothetical — the ULD dataset was carrying four sources with verdict UNREACHABLE and no timestamp, three of them the carrier pages that are the entire evidential basis for PGA’s tare weight, and every one of them counted as a full primary source. The bar was measuring the same thing the old “≥4 independent sources” rule measured — the shape of a citation rather than its content — one level up from where that defect was last fixed. The bar now takes evidence: a source counts only if it was read (a confirmed verdict, a parseable timestamp, and a basis-check that did not fail), or is an access-restricted authority cited by identifier and clause, which contributes but can never be the only leg — a record whose citations are all clause references has no read behind it. Passing bare classes now throws rather than quietly returning false, because a caller that “fails closed” while believing it evaluated the bar is the same false assurance in a politer form. A new build gate proves it goes red on the 403-walled-primary case. No dataset status changed and no served value moved: this is the gate, not a re-grading.
meta.source on /api/uld read “≥4 cited sources per record, each read and verdicted 2026-08-03”. That was true of the 77 citations when it was written. A re-sourcing pass later the same day added 71 more, taking the dataset to 148 — and those 71 carry first-agent verdicts only. The sentence had quietly become an over-claim about verification work, which is the failure mode this particular line has now been corrected for three times. It now states the split: 77 second-agent verdicted, 71 awaiting that read. The record status was never wrong — all 16 ULD records remain pending-verification with medium confidence and a PROVENANCE_PENDING advisory, exactly as before.
Two numbers were living in one field. max_gross_weight was holding both a structural rating — what the unit is certified to carry, published by its manufacturer with no aircraft or deck attached — and a carrier operational limit, which is what a named airline will actually load on a named aircraft, deck and position. Those answer different questions, and the field never said which. That is why PAG served 4,626 kg while PAJ served 6,804 kg for the same 318×224 pallet. Both figures are real: DoKaSch publishes PAG and PAJ at 6,804 kg as one article, while ANA Cargo publishes five different limits for that same PAG — 4,626 on B747, 4,676 on B777, 5,102 on B767ER, and on B767F a split of main deck 3,835 / lower deck 6,803. Every record now carries max_gross_weight_basis and a qualifier naming the publisher, the carrier and the aircraft. Two things we previously published about this were wrong and are corrected here: the earlier entry called PAG’s 4,626 kg “the belly figure” — it is not, it is ANA’s B747 figure, aircraft-scoped rather than deck-scoped; and it said AAP’s 6,033 kg “is published by none of its own sources” — it is published by VRR Aviation as the structural rating and by Korean Air Cargo, which prints 6,033 kg (277 kg). The same correction applies to RAP, whose 6,033 kg is published by Envirotainer, the manufacturer. Note also that “main deck means the higher limit” is false — on ANA’s B767F the main-deck figure is the smaller one, so a deck cannot be inferred, only sourced; per-deck figures are stored only where a carrier actually published a deck-split table. Tare has the same problem, and it is why 13 of 16 tares matched no source. Manufacturers weigh the bare unit — VRR publishes PMC at 88 / 93.9 / 101.5 kg for 3.0 / 3.3 / 3.7 mm sheet gauge. Airlines weigh what they hand you, net and straps included; Qantas Freight says so in terms: “Tare weight from (includes cargo net)”. For PMC the manufacturer band is 85–101.5 kg and the carrier band 110–125 kg, and they do not overlap. Every record now carries tare_weight_basis, and where no source cleanly attributes the served figure the basis is unverified with the observed band recorded. One served value changed: PGA’s tare weight, 486 kg to 565 kg. 486 kg sat below every published figure including the manufacturer’s own floor — DoKaSch publishes “from 520 kg”. The carrier band is 505–565 kg and the conservative figure within that basis is 565 kg, the safer assumption for payload. Being clear about the evidence: that 486 kg is wrong rests on a source we independently re-fetched at 200; the specific 565 kg rests on three carrier pages our verification probe could not reach, cited access-restricted with no timestamp pending a browser re-read. Still pending on all 16 records, deliberately — these are first-agent reads. Alongside, container specifications were re-sourced from scratch: seven of their ten citations were dead and the only three that resolved were Wikipedia, so the dataset had zero live independent citations; they are now cited from Ocean Network Express, Evergreen Line and Seaco, plus ISO 668 and ISO 1496-1 by clause. Vehicle citations were rebuilt too, including two retired rather than replaced because they never supported the values attached to them: 49 CFR 393.5 is a definitions section that sets no width, length or weight limit, and the ATP Agreement prescribes no body dimension anywhere in its annexes.
The ULD tool description told agents that “as of 2026-07-26 no source carries a timestamped content read and none has been second-agent verified”. Both clauses stopped being true earlier today, when the second agent read all 77 cited sources and 26 confirmed reads were stamped. The status was never wrong — provenance is still pending — but the reason given for it understated the work done and overstated the gap, which is the same defect class as the over-claim it was originally written to retract. It just failed safe instead of unsafe. The line now names the real blocker: provenance stays pending because no record yet has a confirmed primary source plus a second independent non-tertiary source agreeing with it — on each record that second source either contradicted the stored value or did not list the code. It also warns specifically about tare_weight, which no non-tertiary source confirms on any of the 16 records, so any payload derived from max_gross_weight minus tare_weight rests on a figure nothing independent corroborates. This one could not be fixed website-only, which is why it needed a package release. The hosted /api/mcp renders tool descriptions from the installed freightutils-mcp package, not from the website’s own source — so the corrected text only reaches agents once the package is published and the site re-pins to it. The familiar “npm inherits it through the REST proxy, no republish needed” shortcut holds for data and _source, which the proxy passes through live; it does not hold for description text, which is baked into the tarball. freightutils-mcp is now 2.12.3 on npm and in the MCP Registry, and this site pins it exactly. Docs-only in the package — no tool, schema or behaviour change, still 24 tools.
The ULD dataset’s provenance has been asserted, downgraded and re-asserted twice without anyone ever reading the sources. That read has now happened: all 77 record-source pairings across the 16 records were opened and judged. 26 confirmed, 30 disagreed with the value we serve, 19 did not list the code at all, and 2 were lost to a context compaction — those count as no evidence rather than as a pass. All 16 records remain pending, and that is the finding, not a formality. The bar is one confirmed primary source plus one independent non-tertiary source agreeing with it. No record clears it: on every single one, the second non-tertiary source either contradicted the stored value or did not list the code. This is not pedantry about Wikipedia — re-running the arithmetic with the ULD reseller sites promoted from tertiary to secondary changes the outcome for exactly one record out of sixteen. What the responses now carry, per record, is which fields the evidence actually covers, because status is one word for the whole record and cannot say “dimensions confirmed, tare not”. tare_weight is confirmed by no non-tertiary source on any of the 16 records — every carrier and operator page either omits tare or contradicts it, so if you are computing payload from max_gross_weight minus tare_weight, that subtraction currently rests on nothing this run could corroborate. internal_dimensions is confirmed on exactly one record (RKN, by Envirotainer, its manufacturer). external_dimensions is confirmed on 13 of 16, max_gross_weight on 12, usable_volume on 11. No served value was changed. Every disagreement is recorded verbatim next to the citation it came from as mismatch_detail, and they fall into three groups: rounding inside the documented tolerance (1588 vs 1587 kg, 6804 vs 6800), an axis convention the records already explain (ACL Airshop publishes an LD3’s contour-top width where we publish its base), and genuine contradictions needing a source this run could not settle. Changing a figure on one contested read is the “plausible reconstruction” this dataset already refused once on UN/LOCODE, and every record stays pending so nothing is over-claimed in either direction. One structural finding is flagged rather than corrected, and it is the one most likely to bite: max_gross_weight conflates deck-specific limits across records. PAG serves 4,626 kg — which Cargolux publishes as the belly figure — while PAJ and PMC serve 6,804 kg, the main-deck figure, for the same 318×224 pallet, so an agent comparing PAG with PAJ sees a 47% difference between near-identical units. DSV states the split outright for AAP (“Lower deck 1,587 / Main deck 6,000”), and AAP is worse still: its served 6,033 kg is published by none of its own sources. Assigning a deck to each record needs per-record carrier attribution this run did not capture, so the contradiction is documented on the affected records and the numbers are untouched.
code was not a parameter this endpoint understood, so it was silently discarded and the request fell through to the unfiltered branch: count 16, every record, HTTP 200, no warning. An agent taking results[0] got AKE by luck of alphabetical ordering — and would have got the wrong unit for any other code. The response field is literally named code, so it is the obvious thing to filter by. code is now an accepted alias for type and returns one record. More importantly, any unrecognised query parameter now returns 400 naming it rather than being ignored, so a filter this endpoint does not understand can never come back looking like a successful unfiltered query. type, category and deck are unchanged, and no field was removed from any response.
Three records (UAMIR, UAMYR, ZAUHL) shipped longitudes above 180° and were withheld in July on the assumption we had mis-parsed a ×10 decimal shift, with a correct re-parse as the fix. The second agent read the raw strings UNECE actually publishes, and the assumption was wrong. Feeding those published strings — “4829N 38150E”, “4939N 30590E”, “2946S 31219E” — through UNECE’s own documented ddmmN/S dddmmE/W format reproduces 381.83, 306.5 and 312.32 exactly: the three bad longitudes, to the digit. The old code applied the format correctly. The source string is what is out of range, and UNECE’s own page-generated map link reaches the same impossible number from the same string. “4939N 30590E” even carries 90 minutes, which is not a valid minute value in any reading. So there is no better parse to reach for, and all five stay withheld — but they now say something truer. Their basis moves from describing our stored value to describing the authority’s: withheld-source-out-of-range for the three, and withheld-source-placeholder for GBWSG and Wellampitiya, whose rows exist but publish the literal placeholder “0000N 00000E”. Both now carry the UNECE URL and a timestamped read, so the absence is sourced rather than merely asserted. The read was edition 2025-1 while the shipped data is 2024-2, so the invalid strings persist in the newer edition — this is not a stale-edition artefact. A new build gate pins the one parser these five go through, and its most important assertions are the negative ones: a 4-digit longitude is refused rather than padded to 38.25° or shifted to 381.83° — both plausible, both unverifiable — and the 0000N 00000E placeholder is recognised explicitly, because 0,0 is a perfectly valid coordinate that no bounds check could ever catch.
The European Commission’s 3 August 2026 revision of the ICS2 Common Repository stop-words list is in force today, and the checker mirrors it as of today. Two goods-description terms are added — GENCA and General items, both dated 03.08.2026 — and the six terms the Commission had marked “Temporarily disabled” are restored: Fish, Glass, Iron, Steel, Metal and Model. The shipped goods-description list goes from 245 terms to 247, and from 239 active to 247: nothing is temporarily disabled any more. The flag rate will go up, and that is the list doing it, not a change to how matching works. Those six are ordinary English words. A description reading “steel” or “glass” passed this tool yesterday because the Commission had switched those entries off; today it flags, because the Commission switched them back on. Expect this to reach descriptions that are genuinely specific: “stainless steel valves” now returns a flag on Steel, as do “cast iron manhole covers”, “glass bottles for laboratory use” and “frozen fish fillets”. Those are flagged as embedded, not standalone — which is the distinction that matters. An embedded flag says the description contains a term that is unacceptable on its own; it is not a verdict that your description is too vague, and “stainless steel valves” identifies a product perfectly well. The note returned with each flag says exactly that. If this reads as noisy on specific descriptions, the disagreement is with the Commission’s list, not with the checker — and mirroring it verbatim is the point of the tool. /api/ics2-check, ics2_check and /ics2-check mirror the EU list verbatim including the Commission’s own statuses — no editorial override — so a status flip lands here whichever direction it goes. The matcher itself is untouched: still word-boundary, so “fisherman”, “ironing board”, “modelling clay” and “fibreglass” are all still clean, and “GENCARGO” does not trip “GENCA”. The standing caveat is unchanged and still the important part: the list is non-exhaustive, a flagged term will likely cause an ENS rejection, and a clean result does not mean the declaration will be accepted. The wave had been staged since June — the document sat on CIRCABC with a future date, recorded here with its node and its sha256, under a standing rule not to ship it until it was effective. Applying it today started by re-fetching that document over the no-login guest path and confirming the sha256 was byte-identical to the value recorded when it was staged; a document that had changed after being recorded would have been a finding, not a formality. Terms were then re-extracted deterministically rather than transcribed, and the extractor was validated by running it against the previous in-force PDF and requiring it to reproduce the previously committed 245-term fixture exactly, in order, before its output on the new document was trusted. The diff is exactly the two additions and the six status flips: nothing was removed, no other term or date moved, and the party-field rows are unchanged at 26. Every count here is derived from the document; none is typed. Why the six were disabled in the first place is still not known, and the source does not say — the prose of the 4 May and 3 August documents is byte-identical, the only footnote in either explains what “Initial list” means, and the marker sat in the implementation-date column with no note attached to it. The six revert to “Initial list”, their original Spring 2025 status, rather than to a new 3 August date. That question is now recorded against the data as unanswerable from the primary. The daily CIRCABC drift watcher reads 3 August 2026 as the current in-force version with no upcoming version pending, and the build gate that hard-asserts every shipped term against the committed authority snapshot was extended to cover this wave — including that the snapshot’s sha256 and the watcher’s baseline can never silently disagree. No endpoint, tool or schema changed (still 23 REST / 24 MCP) and no npm republish is needed: ics2_check is REST-backed, so the hosted surface serves the new list to every consumer at once.
All six pallet records — PMC, PMCQ7, PLA, PAG, PGA and PAJ — stored internal_dimensions byte-identical to external_dimensions. A pallet has no walls and no roof, so it has no internal dimensions to report. The consequence was arithmetic: an agent multiplying PMC’s internals got 12.65 m³, against the same record’s stored usable_volume of 11.5 — a 10% disagreement describing a space that does not exist. All six now return internal_dimensions: null with internal_dimensions_basis “unbounded-open-top”, which is the convention the flat-rack containers already use rather than a new one invented for this dataset. The 163 cm that had been sitting in PMC’s internal height is not a wall — it is the aircraft contour ceiling that cargo may be built up to. It now lives in max_build_up_height_cm, alongside max_build_up_height_deck stating which deck the figure applies to. PLA and PAJ carry no deck attribution in the record, so theirs is null and flagged rather than guessed at. Every one of the 16 records also gained capacity_basis, including a new value: build-up-envelope. A pallet’s usable_volume is real, sourced and operationally useful, but it is bounded by the aircraft the unit flies in rather than by any enclosure the unit has — reusing “carrier-published” would have re-encoded the exact ambiguity these fields exist to remove. The provenance flag was the larger problem. All 16 records shipped verified with high confidence, while chrome_verified_at was null on every one of them and sources was an array of bare URL strings with no accessed_at slot at all. The dataset was structurally incapable of recording the proof of the claim it was making. sources is now {url, accessed_at} on every record, all 16 are pending, and /api/uld and the uld_lookup tool now return provenance_status pending-verification with medium confidence and a PROVENANCE_PENDING warning — the same treatment UN/LOCODE already had. UN/LOCODE was the more honest dataset reporting the lower confidence, and that inversion was being held in place by a test fixture asserting “uld verified”. To be clear about what the downgrade does and does not mean: all 29 cited URLs were checked and 29 of 29 resolve. This is not link rot, and the numbers are not suspected of being wrong. The gap is that nothing has been read against its source with a timestamp, and accessed_at stays null rather than being stamped from a liveness probe — a 200 proves a URL answers, not that the page still says what was transcribed from it. Earning verified back is a content read, and the integrity test now refuses verified without both a stamped source and a second-agent confirmation, so the claim cannot return unearned. No endpoint or tool was removed. external_dimensions, max_gross_weight, tare_weight and usable_volume are unchanged.
A sweep of all 116,129 UN/LOCODE records found five that could not be true. Three stored a longitude beyond 180° — UAMIR at 381.83, UAMYR at 306.5, ZAUHL at 312.32 — which is not a close call about accuracy, it is off the planet. Two more stored exactly {0, 0} while describing places in Essex and in Sri Lanka: a sentinel value, not a location, and one an out-of-range check would never have found because 0 is a perfectly valid coordinate. All five now return coordinates: null with coordinates_basis saying which failure it was — withheld-out-of-range or withheld-null-island — so the reason travels with the absence instead of the record simply going quiet. None of the five was repaired, deliberately. The ×10 reading is compelling: ZAUHL divided by ten lands Umhlali within about 0.02° of where it actually is. That is precisely what makes it dangerous — a plausible reconstruction is how a wrong number gets shipped with confidence. UNECE returns 403 to us on every country page tried, so there is no fetched authority to restore from today. Withholding is honest; dividing by ten is a guess wearing a decimal point. Restoring any of the five requires a fetched source and a timestamp. nearest_airport was never affected: it ranks against a separate airports dataset that swept clean, and it already validates caller-supplied bounds. A new build gate ships with this — seven rules that ask whether a record could physically exist at all, rather than whether its fields have the right types: a longitude beyond 180°, an internal dimension larger than its external, a volume that disagrees with its own dimensions, a unit mass below its own tare. Against the data as it stood before this release it found 36 violations. Against the data as it ships, zero.
July 2026
Container specs carried a type violation that broke arithmetic silently. euro_pallets was a STRING on /api/containers and container_lookup — and four of the ten values were ranges written with a typographic en dash, like “23–24”. The same field name is a plain number on /api/vehicles. So an agent doing arithmetic on a container’s pallet capacity got NaN from Number(“23–24”), or silently got 23 from parseInt — the lower bound only, the upper discarded without a warning. Both fields are now numeric pairs: euro_pallets_min plus a nullable euro_pallets_max for the genuine ranges, and the same for gma_pallets. The old string fields are unchanged and still returned — marked deprecated in the OpenAPI spec, and now derived from the numbers so they cannot drift; they will be removed in a future major version, not this one. The two flat racks reported a cubic capacity that is physically impossible. A flat rack has no roof and no side walls, yet the 20ft stored a 228 cm internal height and 31.1 CBM — and 31.1 is exactly 583 × 234 × 228, the volume of a box using a height that bounds nothing. Those two were the only records whose capacity matched length × width × height exactly; every other sits 0.1–0.7 CBM above it, because carrier capacities are not box multiplications. That exactness is what identified them, and Hapag-Lloyd’s own flat-rack pages confirm it: they publish no capacity figure at all for flat racks, only a loadable envelope, while every other type on that site has one. Both records now return a null height and null capacity with height_basis “unbounded-open-top”, plus deck_area_sqm (13.64 and 27.99 m²) — the figure that actually means something for an open deck. Open tops are a genuinely different case and are now modelled as one: they publish a real internal height that applies with the roof fitted, and the roof comes off for crane loading, so they carry height_basis “constrained-removable-roof”. Two stored numbers were wrong and are corrected against a live-fetched carrier source. The 40ft reefer stored ISO code 45R1 — a 9’6” high cube — alongside standard-height dimensions (259 cm external, 218 cm internal), contradicting every other record in the dataset carrying that same code character, which store 290 cm. Hapag-Lloyd’s 40ft High Cube Reefer page confirms ISO 45R1 and a 2,425 mm internal height, so the heights were the error: now 242 cm internal, 290 cm external, 255 cm door, capacity recomputed to 63.9 CBM. The 45ft High Cube stored a 30,480 kg max gross beside a note admitting operators publish 32,500 kg — knowingly storing a figure the record said nobody uses, understating usable payload by about 2,020 kg. The carrier publishes 32,500 kg gross and 27,700 kg payload, and its tare matches ours exactly; those are now stored. Every record also gained fields stating what its numbers mean — capacity_basis, max_gross_basis, pallet_capacity_basis and open_top — because all ten stored a 30,480 kg max gross, which is the ISO 668 ceiling rather than an operational limit, so payloads derived from it understate what carriers run. And the ten records previously shared one byte-identical 473-character “provenance” paragraph; each now carries its own, with its own arithmetic. Two new build gates ship with this: one asserts no field name can carry two types across datasets and no quantity is stored as a string, the other checks every container’s ISO 6346 size code against its stored external height. A repo-wide sweep of every remaining dataset is published at docs/audit/cross-dataset-integrity-sweep-2026-07-25.md, including three UN/LOCODE records shipping longitudes above 180°. No endpoint or tool was removed.
Two records in the road-vehicle reference set — the 13.6m flatbed trailer and the low-loader — stored an internal height of 0. That was never a measurement; it was a flag meaning “open deck, no roof”, and the record’s own provenance note admitted it. But a zero in a numeric field is a wrong answer with a confident shape: anything multiplying length × width × height got 0 m³ for a flatbed. Both records now return height: null, plus two explicit fields — open_top: true and height_basis: “unbounded-open-top” — so the reason travels with the value instead of having to be guessed. Every one of the 17 records carries height_basis, because a bare null would only have moved the problem: “no height limit exists” and “a limit exists but we don’t have a trustworthy figure” are different answers, and nothing should have to infer which is meant from the absence of some other flag. Three numbers were corrected where the record contradicted itself. The flatbed’s payload was 27,000 kg while its own stored working read “44t gross − 12t tractor − 6t trailer = 26t” — that 26t figure had been rounded up for spec-sheet tidiness, so the stored number disagreed with its own arithmetic; it is now 26,000 kg (the underlying tare assumptions are themselves unverified and are flagged, not adopted). The double-deck trailer stored a 2,700 mm internal height with a note claiming “two decks of approximately 1,350 mm each” — 2 × 1,350 is exactly 2,700, leaving no thickness for the deck between them, and 2,700 mm turned out to be the standard curtainsider’s value copied across. Don-Bur’s published double-deck specification (fetched live, and the only citation in this dataset with a real timestamp on it) gives a 3,033 mm internal aperture at roughly 1,500 mm per deck for a step-frame build inside the European 4.0 m height limit; the record now stores those figures and says which configuration they describe and that taller UK builds exist. And the LWB Transit van’s note claimed three Euro pallets fit “in a single file” — three at 1,200 mm each is 3,600 mm against a 3,400 mm load length, so that basis was false. The count itself was left alone and flagged rather than guessed at. Every record also gained pallet_capacity_basis, which finally answers a question the data never did: is this count how many pallets fit on the floor, or how many the payload allows? The 13.6m artics’ 33 Euro pallets is genuine floor geometry (11 rows of 3). The 7.5-tonne rigid’s 8 is not — its floor holds 15, but 2.5 tonnes across 15 pallets is 167 kg each, so weight runs out long before space does. Seven records are marked unverified, the honest answer where we cannot yet tell — including the 26-tonne rigid, whose dimensions and both pallet counts are byte-identical to the 18-tonne rigid despite 40% more payload. The arithmetic for all 17 assignments is in each record’s provenance note, and the open questions are documented at docs/audit/vehicles-lane1-findings-2026-07-25.md — including that 28 of the dataset’s 51 cited source URLs return 404, so the regulatory prose here should not be trusted until a real re-sourcing pass runs. The verified: false flag and the pending-verification caveat stay exactly as they are. Two new build gates ship with this: one proves no consumer of a vehicle height can emit 0, NaN or a silent omission, and one checks the dataset against every other place in the codebase that asserts a vehicle fact. No endpoint, tool or field was removed; /api/vehicles and the vehicle_lookup tool gained four fields and are otherwise unchanged, and the npm package needs no republish.
The Harmonized System dataset was the largest reference surface still marked “pending verification” — pending only because it had been sampled (50 codes), never fully diffed. It now is. Every one of the shipped 6-digit international codes (levels 2/4/6 — chapters, headings, subheadings) was checked by script against four independent open customs authorities fetched live: the UN Comtrade H6 nomenclature (the WCO HS 2022 published list, public-domain), the US HTS (USITC), the UK Trade Tariff (HMRC) and World Bank WITS. The diff is clean: every code exists in HS 2022 at the correct level and parent, no invented or pre-2022 codes, no HS 2022 code missing, tree integrity intact — and every shipped code is backed by at least three of the four authorities (most by all four). The one correction: three “Commodities not specified according to kind” entries (99, 9999, 999999) were removed — these are UN Comtrade’s use of the WCO-reserved chapter 99 for statistical catch-all, not part of the HS 2022 international nomenclature (they are absent from the US, UK and WITS lists). The HS index is therefore 6,937 nodes (was 6,940). Descriptions were re-confirmed against the UK and US subheading text. As a result the /hs pages and the homepage no longer show the pending caveat for HS, and every HS API and MCP response now returns provenance_status verified with high confidence and no pending warning. A new build gate (lint:hs) pins a representative sample of codes across every chapter — including the duty-lookup fixture and the six previously-corrected subheadings — to the authorities, so the dataset cannot silently drift back. Full audit: docs/audit/hs-verification-2026-07-24.md. No change to any code you can look up beyond the three removed catch-all entries; no change to the npm package contract.
Yesterday’s fix made connecting to the MCP server free of the daily rate limit, guarded instead by a light per-IP connection burst cap. That per-IP cap left one edge case: a hosted-agent platform or an enterprise network that routes many users through a single shared outbound IP could, at peak, exceed the connection burst and get briefly refused mid-connection — the same failure, but for the highest-volume clients. The connection burst cap is now identity-aware. Requests carrying a valid API key are counted per key at a high ceiling (600 connections/minute each), so every user behind a shared IP gets their own independent connection budget and large keyed clients are effectively unthrottled for connecting. Anonymous (keyless) connections are still counted per IP, with the cap raised from 60 to 120/minute for small shared networks. Nothing changed for actual tool calls — the daily and monthly usage quotas (25/day anonymous, 100/day free key, 50,000/month Pro) are exactly as before, and the scrape protections are untouched. Website-side only; the npm package is unaffected.
The homepage hero stat strip and the tool grid showed “· verified [date]” for HS codes, airlines and UN/LOCODE — all three carry a pending-verification provenance flag (only a sample or anchor set has been audited, not the full published list), which the same page’s “Sources you can audit” table and each dataset’s own page already reported as pending. Those two surfaces were sourcing a verified-date straight from the last-audit field, independent of the derivation. Now the verified-date derives from the single provenance flag (datasetProvenanceStatus, the same one the API’s _source.provenance_status carries): a surface shows “verified [date]” only when independent verification is actually complete, and a pending dataset shows “provenance pending verification” instead — consistent across the whole site. The /methodology dataset table, which had omitted airports, the EU ICS2 stop-words list and the identifier check-digit schemes, now enumerates every reference dataset with its status read from the same flag. A new build gate asserts no web surface can render “verified [date]” for a dataset the flag hasn’t verified, and that both the homepage provenance table and /methodology list every dataset — so this class can’t recur on a page.
Connecting to the hosted MCP server (/api/mcp) and listing its tools no longer counts against an agent’s daily rate limit. Previously the anonymous 25-requests-a-day cap was applied to the whole MCP surface — the discovery documents, the connection handshake, and the tool calls alike — so an anonymous agent could be cut off mid-connection before it ran a single tool. The surface is now split into a control plane and a data plane: connecting and discovering (initialize, tools/list, the notifications and list methods, and the .well-known discovery documents) is free, bounded only by a light per-IP burst cap, while the daily and monthly quotas — 25/day anonymous, 100/day free key, 50,000/month Pro — now apply only to actual tool calls. Every existing quota is unchanged. When an anonymous caller does exceed the tool-call limit, the tool now returns a clear, model-readable message pointing to a free key or a Pro upgrade instead of a bare error. Two supporting fixes ship alongside: the OAuth Protected Resource Metadata document at /api/mcp/.well-known/oauth-protected-resource now returns a valid response (it was a 404 that also counted against the limit), and the MCP endpoint now rejects cross-site browser requests that carry a foreign Origin as a DNS-rebinding safeguard, while leaving native clients — which send no Origin — untouched. No change to the tools themselves (still 24) or to the npm package.
The per-dataset provenance status the API returns (and the caveat the pages show) was reconciled against what the underlying audit records actually contain, and is now derived from them so the two can’t drift. Two datasets moved to verified: air-cargo ULDs — all 16 records cross-checked against at least four independent authoritative sources each — and Incoterms, whose 11 Incoterms 2020 codes and mode categories were diffed live against the ICC-published rule list; their pages no longer show the pending caveat and their lookups now return high confidence. Two moved to pending: HS codes and UN/LOCODE — only a sample or anchor set had ever been audited, not the full published list, so the honest status is pending until a complete diff is run, and their pages now show the caveat. The dataset-level status is now computed from the per-record audit data rather than hand-set, and a build gate blocks any future contradiction: a dataset can never report “verified” while one of its own audit records is still unverified, and confidence can never read “high” on a pending dataset. ADR, airports and the deterministic calculators are unchanged; UK duty remains a live request-time lookup.
The npm package’s uld_lookup description and the hosted /api/mcp surface were updated to match the ULD dataset’s reconciled verified provenance. The tool’s limitations line no longer says provenance is pending independent verification — it states the specs are compiled from manufacturer and carrier-published sources and provenance-verified against at least four independent sources per record, while noting airline-specific ULD variants still differ (confirm operationally critical dimensions with the carrier). The response envelope’s provenance_status for uld_lookup now returns verified with high confidence. Docs-only patch — no tool, schema or behaviour change; still 24 tools. airline_lookup, container_lookup and vehicle_lookup keep their pending-verification notes.
The API has always returned a per-dataset provenance status on every response (_source.provenance_status: verified or pending-verification), but the web pages didn’t show it — a vehicle or container spec page rendered bald numbers while its own API said the underlying provenance was still pending independent verification. That gap is closed. Every reference page whose dataset carries a pending-verification flag now renders a calm, informational caveat stating that the values are compiled best-effort from published manufacturer, standard and regulatory sources with independent verification still pending, and to confirm against the plate, carrier or operator guide. Verified datasets show no caveat and the deterministic calculators are unaffected. The caveat text is single-sourced with the API’s own wording (no drift), renders identically in light and dark mode, and adds no layout shift. The /methodology page gained a per-dataset Status column reading the same live flag, so nothing is listed as verified while its data is still pending. A build gate fails if a pending dataset ever ships a page without the caveat.
Many UN numbers carry more than one row in ADR Table A — different packing groups or concentration bands with genuinely different limits. UN 1789 (hydrochloric acid) is PG II with a 1 litre limited-quantity limit and transport category 2, OR PG III with a 5 litre limit and transport category 3; UN 1790 (hydrofluoric acid) has three concentration bands with different categories again. The LQ/EQ checker and the 1.1.3.6 exemption calculator used to silently pick the first row and return a confident verdict — which could be the wrong packing group. Now, when a UN number resolves to more than one row and you have not said which, both tools withhold the verdict and return the candidate rows for you to choose from, rather than guessing. Both tools gained optional packing_group (I / II / III) and variant_index inputs to pin one row; single-row UN numbers behave exactly as before. The web LQ/EQ checker and points calculator now show a packing-group / concentration selector for multi-variant UN numbers and hold the result until you pick one. A build gate enumerates every one of the 366 multi-variant UN numbers and asserts neither tool can emit a bare verdict for them, with UN 1789 / 1790 / 1791 / 1796 pinned as permanent regression cases.
The npm package’s adr_lq_eq_check and adr_exemption_calculator tools mirror the fix: both accept optional packing_group and variant_index, and a UN number that resolves to multiple ADR Table A rows without one returns ok:false with blocking_errors [{ code: "AMBIGUOUS_UN_VARIANT" }], human_review_required and candidates[] (each candidate’s variant_index, packing group, proper shipping name and decision fields) instead of a wrong verdict — the ambiguous response is HTTP 200 so the candidates flow through the stdio proxy intact. Single-row UN numbers are unchanged. Still 24 tools, no name changes; a minor version bump because the new inputs are additive and backward-compatible.
The air ULD reference behind the CBM calculator (/cbm) and the ULD lookup (/api/uld, /uld, and the uld_lookup MCP tool) now agree on every IATA type code. A focused re-audit found the CBM side had several mislabels: AAP was shown as LD7 (it is LD9); the M1 container carried the wrong code AAF (the M1 code is AMJ — AAF is the separate lower-deck LD-26); the LD11 (ALP) had a 223 cm width copied from a full-width unit (it is 153 cm, ~7.2 m³); an “LD9 (AAG)” entry duplicated the LD9/AAP container under a code that is not IATA-registered; and a “P6P” pallet listed a 498 cm length that matches no real unit (the 20-ft main-deck pallet is PGA at 606 cm). On the ULD side, the PLA half pallet was mislabelled “P1P” — a designation that belongs to the 88-inch PAG full pallet, not the 60-inch half pallet. Every corrected code was verified against ≥4 independent authoritative sources (carrier ULD guides, pool operators, forwarders) with independent adversarial re-checking. The affected /cbm pages were renamed to their correct codes with permanent redirects from the old URLs, and a build gate now blocks any future case where one IATA code maps to two different names or dimensions across the two datasets.
Three reference-data fixes from the monthly methodology audit. HS codes: six HS 2022 subheadings were showing their parent heading’s full title instead of the subheading’s own scope — corrected to the actual 6-digit meaning (for example 9111.90 now reads as the parts of watch cases, not “watch cases and parts thereof”; 8472.90, 7306.90, 7419.90, 8477.80 and 5702.10 similarly). Air ULDs: the LD1 (AKC) and P1P pallet had inherited a neighbouring unit’s dimensions — the LD1 now carries its own contoured 234 × 153 × 163 cm / ~4.7 m³ (a distinct, larger unit than the LD3, not the same volume), and the P1P its correct 88-inch base (318 × 224 × 244 cm), narrower than the 96-inch PMC/P6P pallet; two adjacent entries (M1, LD9) were de-conflated in the same pass. UN/LOCODE: a code lookup now flags locations UNECE has not officially approved (requests under consideration, user entries, unverified or to-be-removed codes) with a provisional-status warning and lower confidence, so an unapproved code isn’t treated like an approved port. A build gate now guards both drift classes; every corrected value was checked against at least four independent authoritative sources.
The MCP server now has its own page at /mcp instead of scrolling into the API docs. It lists all 24 agent tools grouped by category (calculators, reference, and the MCP-only subscribe helper) with a one-line summary and a link to each tool’s own page, shows install snippets for Claude Desktop, Cursor and Cline plus the remote URL, and explains the response envelope every tool returns: a typed result with confidence, provenance and a ready-to-quote citation an agent can both use and cite — one API request per successful call. Rate-limit tiers (anonymous, free key, Pro) and API-key passthrough are documented alongside directory links (npm, MCP Registry, Smithery, Glama, Postman). The MCP link in the top navigation now goes straight here; the API docs remain the REST reference, cross-linked both ways.
The sign-in, confirm, password-reset and security pages now share the same modern design as the rest of the site, in light and dark mode. Sign-in leads with the Google/GitHub/Microsoft buttons and adds a quiet note for anyone who signed up with a provider and may not have a password yet. The account page gained a plan card showing your tier, price and renewal (or end) date for Pro, a usage meter against your quota with the reset time, and API-key controls to reveal, copy and regenerate your key inline (regenerating invalidates the old key immediately). Billing, invoices, card and cancellation stay in Stripe’s secure portal via the Manage billing button, with refund-policy and terms links alongside.
The npm package now fetches the response envelope with the legacy_source bridge and reconstructs the flat legacy text channel from it — one API request per successful call instead of the two that 2.11.0 introduced, so anonymous and Pro rate-limit budgets are consumed once per call again (verified live: X-Ratelimit-Remaining decrements by exactly 1). content[0].text stays byte-identical to 2.10.x/2.11.x for every tool (32/32 byte-parity checks against production, success and error paths, POST tools, the emissions rich-text layout and the adr_lookup 404); structuredContent remains the clean public v1 envelope, byte-equal to the default ?envelope=1 response. Errors fall back to the flat call for exact legacy error bytes (2 requests — the rare path). Supersedes the 2.11.0/2.11.1 double-request caveat.
The response envelope gains an opt-in legacy_source bridge field (?envelope=1&legacy_source=1) that lets the freightutils-mcp package rebuild the flat legacy body from a single request instead of two. Bridge-internal, not part of the public v1 contract; the default envelope and the flat channel are byte-unchanged.
All 24 tools now share one modern page language. Lookups (ADR UN numbers, HS codes, Incoterms, airlines, airports, UN/LOCODE, containers, ULDs, vehicles) render as reference cards: a monospace identifier headline, a category-colour badge, an at-a-glance specification grid, and a provenance footer showing the source, its last-checked date and verification status — with a one-click "Copy citation" that copies exactly the citation the API envelope carries. Calculators (CBM, chargeable weight, LDM, pallet fitting, unit converter, emissions, UK duty, ADR exemption and LQ/EQ checks, consignment) recompute live and show their work: the governing figure large, the full formula with your numbers, a method line naming the convention and parameters used (IATA divisor, W/M rule, GLEC factors…), and "Copy result" pasting value, derivation and method as one line. Every page is themed by category (freight-ops green, dangerous-goods red, customs blue, reference purple) with subtle line-art, works fully in light and dark mode, and meets WCAG AA — keyboard reachable, screen-reader announced results, reduced-motion respected. Honest provenance throughout: pages whose datasets are still pending independent verification now say so consistently. Same URLs, same calculations, same data — nothing to relearn.
Docs-only patch: the uld_lookup tool description said "15 types" while the ULD dataset holds 16 — caught by the pre-merge adversarial review and corrected upstream. The hosted MCP endpoint now serves tool descriptions generated at build time from the pinned npm package, so the hosted and npm definitions can never drift again (a build gate fails on staleness). No tool, schema, or behaviour change.
The npm package now declares a typed outputSchema on all 24 tools and returns structuredContent: the FreightUtils v1 response envelope, built by the API itself — the answer under result, plus confidence, normalized_input, warnings, _source and a ready-to-use citation, identical to the hosted MCP server and the REST ?envelope=1 opt-in. Every tool definition was rewritten for agent intent-matching: accepted input formats and defaults spelled out per parameter, behaviour prose covering result limits, rounding, no-match and rate-limit signaling (retry_after_seconds), an explicit "Returns:" line backed by the schema, proactive limitations ("structural check, not a registry lookup"; "reference data, not legal or compliance advice"), and cross-references between related tools. Compatibility: the flat JSON text channel is byte-identical to 2.10.x, including error behaviour, so existing text-parsing consumers are unaffected; note each call now makes two API requests (flat + envelope), which counts double against rate limits for stdio users. The hosted /api/mcp endpoint reports 2.11.0 by construction. Still 24 tools, no name or input-schema changes.
The agent-facing response shape that debuted on 3 tools now covers every REST endpoint and MCP tool. It wraps the answer under result and adds confidence (with basis: deterministic, provenance, match_quality or freshness, and a 0–1 score for fuzzy matches), normalized_input (what was interpreted or defaulted), _source, citation, and — where relevant — warnings, blocking_errors (each with a recovery action) and time-boxed validity. Behaviour matches each tool: deterministic calculators are high-confidence; reference lookups carry their provenance status (verified → high, pending-verification → medium with an advisory); fuzzy searches (airline, HS, nearest-airport, UN/LOCODE) return a match score plus an honest best-match warning; time-boxed tools (UK duty, ICS2) carry validity. Opt-in, so nothing changes for existing consumers: add ?envelope=1 (or Accept: application/vnd.freightutils.v1+json) on REST; the flat shape stays the default and byte-unchanged. On the hosted MCP server the envelope rides structuredContent while the flat text channel is preserved. Fully documented at /api-docs; the contract is schemas/response-envelope.v1.json and a build gate validates every enveloped response against it. No new endpoints, tools or counts (still 23 REST / 24 MCP).
Bing flagged a batch of page titles as too long for search results. Every title is now 60 characters or fewer (down from up to 80), fixed at the template level so all ~3,000 pages — pallet, container, HS, ADR, airline and calculator pages — stay concise, unique, keyword-first and branded, with a build gate keeping every title within the limit. A handful of ADR pages whose packing-group field holds a long note (e.g. UN 1845, dry ice) no longer overflow. The API docs also now read consistently: 23 REST endpoints, 24 MCP tools (the 23 REST-backed tools plus get_subscribe_link).
The site header showed "Sign in" to everyone, including signed-in users. It now checks your session and shows Account and Sign out when you're signed in (and nothing flashes while it checks). Pages stay fully static — the check is a single client-side call to the session endpoint.
You can now sign in with an email and password: set one from your account's new security settings (or at signup — the create-account form takes a password up front, activated when you confirm your email), reset it via a single-use emailed link, and change it any time. Passwords are stored only as salted argon2id hashes — never the password itself, never shown to anyone. Sign-in links still work as the secondary path and got two fixes: they now last 30 minutes (was 15 — short enough that real clicks were missing the window) and only your most recent link is valid, so a stack of requested emails can't leave you guessing which one works. Sign-in with Google, Microsoft, and GitHub is wired and will appear as the providers are enabled — one account per verified email, whichever way you sign in. The privacy policy documents all of it.
Sessions previously died a fixed 7 days after sign-in no matter how active you were, forcing a weekly magic-link round-trip. Sessions are now a 30-day rolling window: any authenticated visit more than a day after the last renewal quietly extends the session another 30 days, up to a 90-day absolute cap from the original sign-in (then one fresh sign-in starts a new cycle). Signing out still ends the session immediately, everywhere it's checked. Existing sessions carry over — no forced re-login from this change. Cookie security is unchanged (httpOnly, Secure, SameSite=Lax).
Trust additions across the homepage: every tool tile now shows the exact request that reproduces its example (generated at build time next to the example itself — copy the curl or open the live JSON); the provenance table gains per-dataset status badges using the same vocabulary agents see in llms.txt (verified · pending-verification · computed · live) with a legend, plus a "Spot an error? Dispute a value." link to a structured data-dispute issue template on GitHub; a plain-language boundary line under the hero states what FreightUtils is not (booking, filing, tracking, carrier rating, legal determination); a three-path start fork (build an agent / integrate a system / get IT approval) and an operational trust strip (changelog, versioning, deprecation, methodology, IT pack, DPA, status) close the page; and the pricing strip gains a production lane (SLA / higher limits / audit export → contact@freightutils.com). Status badges are display-layer today; the API-level provenance_status semantics land with the API-docs redesign.
The FreightUtils mark is now a versioned asset set under brand/ in the repo: the nav's slash glyph (General Sans Semibold "/", CTA orange #EF9F27) extracted to outlined SVG masters with tile, wordmark and dark-mode variants, PNG exports at every platform size, and regenerated social banners whose dataset counts derive from site stats. On-site favicons and touch icons were re-exported from the same masters — the favicon no longer depends on whatever monospace font the viewing platform picks, so it renders identically everywhere.
The live /api/airlines response meta hardcoded "airlines": 6352 and "last_verified": "May 2026"; it now derives the count from the dataset (6,357) and the date from the provenance record (2026-05-13), so it can no longer drift. The /api-docs example response is regenerated verbatim from a live call and now also shows the per-record provenance an audited record carries (sources, audited_at, decision_rationale) plus the _source envelope, with the new fields documented in the response-fields table. The cargo-airline figure is corrected from a stale 390 to 391 (airlines with an AWB prefix on record) on /api-docs and /methodology, and copy that said "verified cargo AWB prefixes" now says "with cargo AWB prefixes" — the dataset's verified flag (multi-source-confirmed) applies to 284 records, and we don't claim it for the rest. Prevention: a new lint:stale-counts build gate greps every current-state source file and the rendered HTML for exact superseded strings, so a corrected number failing to propagate now fails the build instead of waiting for a manual sweep to notice.
The homepage is now a dense reference surface: a six-cell dataset strip (counts, issuing authority, last-audited date), a Pack→Weigh→Classify→Declare→Land consignment lifecycle, a 24-tile tool grid where every example is executed at build time by the live tool it belongs to (AWB prefix 176 → Emirates; UN 1203 → Class 3 PG II; an ISO 6346 check digit computed by the validator itself), a provenance table mirroring /methodology (including honest "provenance pending verification" rows), rate-limit and distribution sections, and an agent terminal showing a real adr_lookup response with its _source envelope. A build-time pipeline generates every number and example from the datasets and calculation functions — hand-typed counts fail the build gate. Hover-only motion, keyboard-operable menus and tabs, full dark-mode parity, prefers-reduced-motion respected site-wide. The API docs open with the same terminal hero (MCP / curl / GPT tabs plus an npx install strip), and /pricing gains a Teams line (pooled usage across seats — email contact@freightutils.com to talk; no self-serve checkout yet).
The airline dataset has held 6,357 entries since its last additions, but several surfaces still displayed a stale hand-typed 6,352. All current-state surfaces (site stats, tool registry, MCP tool description, the redesigned homepage strip — which now derives the count from the dataset itself at build time — and the brand banners) now state 6,357. Dataset contents are unchanged; the npm package description refresh rides the next scheduled package update.
The ICS2 Stop-Words Checker (ics2_check, /api/ics2-check, /ics2-check) now flags 12 additional goods-description terms that the EU added to the ICS2 Common Repository list on 2 February 2026: Aid products, Comercial, Consumption, Ensemble, Fake, Headwear, Item, Miscelaneous, Miscellaneous, N/A, Oddments and Promotional. The list is mirrored verbatim from the EU CIRCABC source (TAXUD.A.3.003/TA, current in-force document), including the term statuses the Commission itself sets — FreightUtils does not editorialise it. "N/A" is now also treated as a goods-description stop-word per the EU categorisation (it was previously excluded here as a party-field-only placeholder; party-field checking remains out of scope). The shipped list now matches the EU current-in-force set exactly (245 terms; 239 active, plus 6 the Commission has temporarily disabled that this tool therefore does not flag). Reference check only — a flagged term will likely cause an ENS rejection, and a clean result still does not guarantee acceptance. No change to the tool count (still 23 REST / 24 MCP), endpoints, or schema; no npm republish (ics2_check is REST-backed).
Refreshed the agent-facing surfaces so AI agents and directories read the current, accurate picture. Rebuilt llms.txt as an agent-first index — an identity + agent-safety contract, a connect block (MCP remote endpoint + npm + REST + OpenAPI + Custom GPT + key signup), and every tool phrased as the capability an agent searches for; llms-full.txt regenerated to match. The Identifier Validator now leads with its parse-a-string use case everywhere (the /validate page, /api-docs, and the OpenAPI + MCP tool descriptions): hand it any text — a booking line, an email — and it finds and check-digit-validates every ISO 6346 container, IATA AWB and IMO number in it, with single-identifier validation as the secondary path. Both GitHub READMEs and the MCP registry metadata (server.json, package.json) were refreshed to the neutral-reference + validation-layer, source-citing framing, the OpenAI GPT Store is now listed as a distribution surface, and stale tool counts across the repo docs were corrected to the live figures. Copy and documentation only — no tool, endpoint, schema, data or behaviour change; tool counts unchanged (23 REST / 24 MCP). All OpenAPI operation descriptions remain within the 300-character agent-tooling limit.
Shortened the operation descriptions in the OpenAPI spec (/openapi.json, shown on the interactive /playground explorer) so the spec imports cleanly into agent tooling that caps each operation description at 300 characters — e.g. ChatGPT Custom GPT Actions, which otherwise silently fails to load the action. Five over-length descriptions were trimmed (emissions, ics2-check, consignment, validate, airports), keeping the same meaning. Spec text only — no endpoint, parameter, response, or calculation change; tool counts unchanged (23 REST / 24 MCP).
June 2026
The emissions calculator (emissions_calculator, /api/emissions, /emissions) now returns extra self-describing fields — additive only, with no change to any factor value or the calculation. Each result carries a human-readable summary to surface; a mass_basis reminder to use actual gross mass, not chargeable/volumetric weight (a common air-freight mistake); an empty_running field (whether the fleet-average factor already includes average empty running — so you do not double-count an empty-return leg); a representativeness label (low for sea and air, medium for road/rail/inland) with a high-variance warning in the summary for sea/air (a generic default for screening only — real emissions vary materially by vessel/aircraft type, load factor and routing); and a last_verified date on the factor (when it was last checked against its source). The method wording is now "computed using the ISO 14083 / GLEC distance-based method". On the /emissions page, sea/air estimates are rounded harder to signal their imprecision while the API keeps full precision. The freightutils-mcp MCP output schema gains these fields (republished as 2.10.1). No factor values changed; tool counts unchanged.
Fixed airport search (airport_lookup) returning incorrect results for IATA/ICAO code queries; exact code matches now rank first. Searching a code such as "GLA" or "EDI" via the search box could previously surface a name-substring match (e.g. Brazil's "Glauber Rocha" airport for GLA) or small airfields/heliports instead of Glasgow / Edinburgh, because the search truncated its scan before reaching the exact-code record. No data change; the nearest-airport tool and the direct iata/icao lookups were unaffected.
You can now regenerate your FreightUtils API key from your account page — useful if your key may have been exposed. Regenerating issues a new key, immediately deactivates the old one, and shows the new key once (copy it before you leave the page). The account page already shows your plan, masked key (last 4 characters only), usage this month, and a Stripe billing-portal link for managing your subscription. This is a logged-in, account-only page; the older /dashboard link now takes you to the same account page.
Two new reference tools backed by the public-domain OurAirports dataset (85,555 airports). airport_lookup (GET /api/airports, the /airports page, MCP airport_lookup) resolves an airport by IATA code, ICAO code, or name/city search and returns the full record — both codes, name, type (large/medium/small/heliport/closed/seaplane), municipality, region, country, coordinates and elevation — with ranked candidates for ambiguous names. nearest_airport (GET /api/nearest-airport, MCP nearest_airport, and the "Nearest to me" button on the /airports page) takes a caller-provided latitude/longitude and returns the closest airports by great-circle (haversine) distance, with distance_km on each result; optional radius_km, max_results and type filter. Coordinates are input only — never stored or logged (the audit log records neither query nor body, and the Sentry redactor now also drops lat/lon). The data is public domain: all 1,178 large airports were cross-checked against the independent OpenFlights dataset (95.2% agree within 5 km + IATA) and a representative sample — including every outlier — was independently re-verified against Wikidata (all within 0.8 km), with provenance and limitations documented. Built to the tool-quality bar: the website MCP and the npm stdio server register both tools via registerTool with full output schemas + structuredContent and a _source citing OurAirports. Reference data only — coordinates are the airport reference point, not for navigation; verify current codes with IATA/ICAO. Counts are now 23 REST tools / 24 MCP tools; the freightutils-mcp npm package adds both tools and is republished as 2.10.0.
New tool: the ICS2 Stop-Words Checker flags terms from the official EU ICS2 stop-words list (vague or generic goods descriptions that the European Commission deems unacceptable in an entry summary declaration). Paste a goods description and it returns the flagged terms — each with a note on whether the term is the standalone description (an automatic ENS rejection) or embedded in a longer description (make it more specific) — plus a clean boolean, a caveat and a _source. There is no binary accepted/rejected verdict. Available as GET /api/ics2-check, the /ics2-check page (with an interactive checker), and the MCP tool ics2_check. The list is the EU Commission DG TAXUD list (base guidance v4.10 plus the 4 May 2026 update; legal basis Commission Delegated Regulation (EU) 2015/2446, data element 18 05 000 000 — cited, not reproduced) and is non-exhaustive: a clean result does NOT guarantee acceptance, and a flagged term will likely cause a rejection. It is a reference check only — not an ENS filing, not a customs-compliance determination, and not legal advice — aimed at customs and documentation teams, brokers, forwarders and agents building filing pipelines. Submitted descriptions are not persisted or logged. Counts are now 21 REST tools / 22 MCP tools; the freightutils-mcp npm package adds the tool and is republished as 2.9.0.
The Emissions Calculator (/emissions) and Identifier Validator (/validate) pages now include a live, interactive calculator — enter your inputs and get an instant result from the same /api/emissions and /api/validate endpoints, in addition to the existing API documentation and worked examples. Behind the scenes, the site footer's Calculators and Reference columns now render from the single tool registry so they can no longer drift from the navigation, homepage and sitemap, and a new surface-consistency guard asserts that every tool appears on every public surface (footer, nav, sitemap, API docs, llms.txt and the homepage grid), wired into both the lint gate and a daily health check.
New tool: the Identifier Validator checks freight-identifier check digits — shipping container (ISO 6346), air waybill (IATA modulus-7) and IMO ship number — or parses an arbitrary string to find and validate every identifier in it. Available as GET /api/validate, the /validate page, and the MCP tool validate. Returns per identifier: type, normalised form, valid (check-digit pass/fail), expected vs actual check digit, details (container owner prefix + equipment category; AWB airline prefix + airline, reused from the existing airline dataset; IMO number) and a _source citing the scheme. Structural validation only — a valid check digit means the identifier is well-formed, not that the container/shipment/vessel exists or is active; it is not a registry or tracking lookup. The algorithms are implemented from the public schemes (ISO 6346:2022; IATA Cargo Services Conference Resolution 600a; IMO Resolution A.1215(34)); no standard text is reproduced, and they are verified against documented known-good and known-bad examples. Built to the tool-quality bar: the website MCP and npm stdio server register it via registerTool with a full output schema + structuredContent. Counts are now 20 REST tools / 21 MCP tools; the freightutils-mcp npm package adds the tool and is republished as 2.8.0.
New tool: the Emissions Calculator estimates freight transport CO2e per ISO 14083:2023 / GLEC Framework v3.2 — emissions = mass × distance × an emission-intensity factor (kgCO2e/tonne-km). Available as GET /api/emissions, the /emissions page, and the MCP tool emissions_calculator. It returns well-to-wheel AND tank-to-wheel emissions where the factor has both, the exact factor used (value, authority, edition), the tonne-km activity, and a _source citing both the ISO method and the specific open factor. Factors come from three verified open authorities — UK DEFRA/DESNZ (Open Government Licence v3.0), US EPA GHG Emission Factors Hub (US-Gov public domain) and ADEME Base Carbone (Licence Ouverte) — across road, rail, sea, air and inland waterway; GLEC is a methodology citation only and no GLEC values are redistributed. It is the first tool built to the new quality bar: the website MCP and the npm stdio server register it via registerTool with a full output schema + structuredContent (the other tools follow in a later sprint). You provide the distance — the tool does not route or geocode. Best-effort estimate using open factors, not a verified or audited carbon report. Counts are now 19 REST tools / 20 MCP tools; the freightutils-mcp npm package adds the tool and is republished as 2.7.0.
Each FreightUtils REST endpoint and MCP tool response now includes an additive "_source" block citing where the data or method comes from, assembled from the single provenance source of truth. Reference datasets carry authority, dataset/edition, source URL and licence (ADR 2025/UNECE, HS 2022/WCO via UN Comtrade PDDL, UN/LOCODE 2024-2, UK Trade Tariff/OGL v3); the calculators carry a methodology citation pointing at /methodology; and the datasets still completing a provenance audit — airline, container, ULD, Incoterms and vehicle — carry authority plus "provenance_status": "pending-verification" only, with no licence asserted. The website MCP responses add the same _source in structuredContent plus a one-line human-readable citation in the text. Purely additive: no existing field was renamed, removed or retyped, so any agent that ignores unknown fields is unaffected (still 18 REST tools / 19 MCP). Documented in the OpenAPI spec via a new SourceEnvelope schema. The freightutils-mcp npm server inherits _source automatically through the REST proxy — no package republish needed.
The published OpenAPI version label now reads 3.1.0 wherever it appears (the spec file already declared 3.1.0, but the API docs, the spec download buttons and the pricing feature list still said 3.0). Behind the scenes, tool/endpoint counts, dataset editions, per-dataset data-source citations and the "last updated" marker now all derive from a single source of truth, so they stay consistent across the API responses, the dataset pages and the docs and cannot drift apart. No tool, endpoint, schema or behaviour change — still 18 REST tools / 19 MCP.
FreightUtils now leads with one consistent positioning across every source agents and directories read — "the neutral freight reference layer for AI agents": authoritative dangerous-goods, customs, location and freight-calculation data an agent can call and cite, from primary sources (ADR 2025/UNECE, HS 2022/WCO, IATA-regulated airline prefixes), with no freight to sell and no carrier to push. Applied to the homepage title/description and Open Graph, the /api-docs intro, the OpenAPI spec description, llms.txt and llms-full.txt, the GitHub repo About, and the freightutils-mcp package (README, package.json, server.json) — republished to npm as 2.6.0 so the MCP Registry, Smithery and Glama re-sync from the corrected source. Tool counts are unchanged and still derive from the single source of truth (18 REST tools / 19 MCP). Metadata and copy only — no tool, endpoint, schema or behaviour change. @modelcontextprotocol/sdk is verified at or above the 1.26.0 release that patches CVE-2026-25536 on both surfaces.
Several MCP tools advertised a plain text input where the description promised a specific format, so malformed values passed validation and only failed (or returned nothing) further down. Each is now validated to exactly the forms it actually accepts, with a clear error, and without narrowing any feature: unit_converter now takes a known unit code (the 17 weight/volume/length units, plus chargeable_kg and freight_tonnes as conversion targets); hs_code_lookup validates a 2–6 digit code and a Roman-numeral section; incoterms_lookup takes a 3-letter Incoterm code; uk_duty_calculator validates the commodity code (6–10 digits), 2-letter origin country and the Incoterms enum; airline_lookup keeps its 2-char IATA / 3-char ICAO / 3-digit AWB-prefix / country / name union but rejects mis-shaped codes; unlocode_lookup validates the 5-char code, 2-letter country and a function_type enum (port/airport/rail/road/icd/border); and the container/ULD/vehicle selectors reject empty input while still accepting any code or slug. Applied identically to the website /api/mcp surface and the freightutils-mcp npm package (published 2.5.0) so tools/list and validation match across both. No tool, endpoint or count change (still 18 REST tools / 19 MCP). Note for npm users: unlocode_lookup’s function filter is now named function_type.
The consignment calculator can now be shared with a single link. "Copy share link" encodes the entire consignment — mode plus every line (quantity, dimensions, units, goods code and stackable flag) — into the URL itself; opening that link in any browser reopens the consignment as a clearly-labelled, read-only "Shared consignment" view that recomputes client-side, dangerous-goods flags included, with the same totals and billed-on basis the sender saw. Nothing is uploaded, saved or tracked — the consignment lives entirely in the link — and the recipient can hit "Edit a copy" to branch their own editable version. Oversized consignments that would make a broken link are declined gracefully with a prompt to send a CSV/JSON file instead. The four exports (Excel, email, CSV, JSON) now all carry each line's goods code so a pasted-back consignment is complete; the JSON keeps the canonical POST /api/consignment result shape and adds an input echo alongside it. No new tool, endpoint, URL or API change.
The website MCP endpoint (/api/mcp) now declares the prompts and resources capabilities and returns a valid empty result for keyless resources/list, prompts/list and resources/templates/list, instead of a -32601 "Method not found". This matches the freightutils-mcp npm package so both MCP surfaces behave identically when a directory crawler or strict client introspects them without an API key. The tool surface is unchanged — still 19 tools with the same enriched schemas — and tool calls still require an API key only at call time. Website surface only; no new endpoint, tool or count, and no package republish.
On the consignment calculator the results card is sticky so it stays in view while you work, but its sticky scope reached past the tool — so on scroll it stayed pinned and the "How the consignment calculator works" copy slid up behind it, reading as a card floating on top of the page. The sticky is now scoped to the tool section: the results card stays put while the tool is on screen, then scrolls away normally as the explanatory copy comes up. No layout shift, and the mobile layout (where the card is already non-sticky) is unchanged.
The multi-item consignment calculator is now a spreadsheet-style tool. Switch between sea, air and road with a single control — the chargeable-weight basis follows the mode — and add as many lines as you need, each with its own quantity, dimensions (mm/cm/m/in), weight (kg/g/t/lb) and an optional goods code. Results sit directly under the table and update instantly as you type, entirely in your browser: total volume, gross and chargeable weight, what the consignment is billed on, the mode's costing basis, and objective advisory flags including dangerous-goods presence. Type a UN number into the goods-code field and its ADR 2025 hazard class and proper shipping name appear (UN 1203 → Class 3, petrol) — resolved client-side against a slim public index, so the full dangerous-goods dataset is never downloaded to the browser. Four one-click exports round it out: copy for Excel (tab-separated rows + totals that paste straight into cells), copy a plain-text summary for email, download a CSV, or copy the canonical JSON result — the exact same object the POST /api/consignment endpoint returns. The in-browser figures match the API to the last decimal. Separately, the tool and REST-endpoint counts shown across the site now read 18 (matching the live tool registry); the MCP server still exposes 19 tools.
Internal infrastructure only — no change to the consignment calculator's inputs, outputs, schema, flags, or tool counts. The consignment dangerous-goods-presence flag's ADR lookup is now an injected dependency that defaults to the full server-side ADR 2025 dataset, so REST /api/consignment, the MCP consignment_calculator, and the npm package behave exactly as before. A new committed, re-runnable generator (scripts/build-adr-un-index.mjs) projects the canonical ADR 2025 dataset 1:1 to a slim public index at /data/adr-un-index.v1.json (2,939 entries of un_number, un_class, proper_shipping_name) so the upcoming web consignment tool can resolve UN-number presence without bundling the full ADR dataset. The index is drift-guarded in CI against its single source of truth. Reference facts only (presence + class), never a compliance statement.
The consignment calculator (POST /api/consignment and the consignment_calculator MCP tool) now shares one canonical, versioned contract. Pick a transport mode (sea, air or road) and send canonical "lines" — each with quantity, dims {l, w, h, unit} (mm/cm/m/in) and weight {value, unit} (kg/g/t/lb) — or keep sending the legacy flat "items" array, which still works unchanged. The engine returns per-line and grand totals (CBM, loading metres, volumetric and mode-specific chargeable weight; sea uses weight-or-measure at 1,000 kg/m³, air a settable IATA volumetric divisor defaulting to 6000, road loading metres against a 2.4 m trailer width) and stamps a schema_version. It also returns objective, advisory-only flags: an implausible-density check, a mode/option mismatch check, a dangerous-goods-presence note when a line carries a UN number (matched against the ADR 2025 reference — presence only, never a compliance verdict), and an ISO 6346 container / IATA air-waybill check-digit sanity check. Every response carries a best-effort disclaimer — deterministic calculations and reference data only, not regulatory, customs or dangerous-goods compliance advice; you remain responsible for classification, documentation and carrier acceptance. The canonical JSON Schema is published at /schema/consignment.v1.json. Backward compatible: previously valid calls keep working.
The remote MCP tools adr_lookup and adr_lq_eq_check now validate their inputs the same way the freightutils-mcp npm package already does: un_number must be 4 digits (optionally "UN"-prefixed), name search needs at least 2 characters, and the LQ/EQ checker takes 1–20 items. Malformed inputs now return a clear validation error instead of an empty or confusing result. Both tools also gained fuller descriptions of what they return, when to use them, and an explicit note that they return ADR reference data rather than certifying that a shipment is compliant. This brings the website MCP surface into line with the published npm package — no behaviour change for valid calls.
The three API access tiers are now spelled out identically everywhere they appear — 25 requests/day for anonymous access (per IP, no signup), 100 requests/day with a free API key, and 50,000 requests/month on Pro (£19/mo). Previously the free-key tier was missing from the /about "How this runs" section and from the pricing summary that feeds the LLM index, so the real free-key limit read differently depending on which page you landed on. Every number is verified against the live rate-limit middleware, the anonymous limit now reads from the same shared constant the enforcement path uses (so the displayed and enforced values cannot drift), and the OpenAPI spec description states all three tiers for any tool that imports it. Commercial-access enquiries now point straight to Pro pricing. A new docs/RATE-LIMIT-SEMANTICS.md records the exact enforced behaviour: per-tier limits, per-IP vs per-key bucketing, the daily/monthly renewing (not lifetime) windows, and the precise 429 response shape.
Magic-link sign-in now works reliably from corporate email accounts. Many company mail systems (Microsoft Safe Links, Mimecast, Proofpoint and similar) automatically open every link in an email to scan it for threats — and because FreightUtils sign-in links were single-use, the scanner's automated visit was using the link up before the recipient ever clicked it, producing a confusing "link expired or already used" error even well inside the 15-minute validity window. Clicking a sign-in link now lands on a lightweight confirmation page with a single "Continue to sign in" button; the link is only marked as used when that button is pressed, which automated scanners never do. The confirmation step is a plain HTML form, so it works in locked-down corporate browsers with JavaScript disabled. Double-clicking the button or using the back button after signing in no longer shows an error — if you are already signed in, you are taken straight to your account. All sign-in destinations behave exactly as before, including the verify-then-checkout flow for Pro upgrades. If your sign-in link does expire (they still last 15 minutes), the error page and the confirmation page both offer a one-click path to request a fresh one.
Root-cause fix for the customer-tier-sync family (FAULT 16/17). updateUserPlan in lib/auth/kv.ts no longer silently no-ops when there is no usable user record: a paying customer who reached Stripe checkout without a prior free account — or whose record was left as a non-JSON blob / field-dropped shape by a past manual dashboard edit (kv-encoding audit F1) — was resolving as Free with no API key, the /account page showing "key not available — contact support" and the "missing information" banner, and the self-heal backstop could not reach the record because it gates on a stripeCustomerId the blob lacked. On any active-subscription write (plan: pro) updateUserPlan now materialises a complete record — minting + linking an fu_live_ key (reusing a recoverable legacy email:<email> key first, so nothing is orphaned) — and repairs a record that lost its apiKey regardless of plan. Persistence is email-independent by construction: the persist path performs only KV writes (user: + key: mirror + email: index) and NEVER calls Resend, so a silent email failure can no longer leave a half-written record; the webhook fires notification email best-effort AFTER the write, with the existing Sentry skip-capture. Sync-only (plan: null) and downgrade (plan: free) events against a missing record stay a no-op — we never fabricate accounts. New admin repair tool scripts/admin/repair-pro-record.mjs rebuilds an absent/malformed/key-less record end-to-end (Stripe-gated, persist-first, key emailed via the standard Resend template only when newly minted). New regression suite scripts/test-provisioning-record-integrity.mjs pins: an account with an active Pro subscription resolves to tier=pro with a linked key independent of email-send outcome, plus malformed-blob and key-less repair cases. Audit: docs/audit/customer-pro-provisioning-2026-06-08.md.
GET /api/mcp/sse threw "redisUrl is required" as an unhandled rejection in prod (Sentry FREIGHTUTILS-5). mcp-handler routes three transports — mcp (streamable-HTTP), sse, and message — but sse and message need a Redis URL (REDIS_URL / KV_URL) for pub/sub that this deployment never configures, so those two endpoints threw the moment they were hit and have never worked; no real client uses them (Claude Desktop, Cursor, the MCP Registry, Smithery all point at /api/mcp/mcp). Fix: a new allowlist (lib/mcp/transport-allowlist.ts) restricts the dynamic [transport] segment to exactly mcp, validated against params.transport at the start of every exported method (GET / POST / DELETE) before any delegation — so sse, message, scanner probes, and random hex all return 404 { error: "unknown_transport", hint } and never reach the Redis-requiring code path. Those rejections are deliberately not sent to Sentry (noise, not faults). As defence in depth the delegation is also wrapped in try/catch, so any error that still escapes on the mcp transport becomes a handled 500 { error: "internal_error" } captured once with tag mcp_route_error=true. The /api/mcp alias forwards a synthetic { transport: "mcp" } context the guard reads (fix-once-mirror-everywhere). Unit + structural tests (scripts/test-mcp-transport.mjs) wired into npm run lint; smoke extended with GET /api/mcp/sse → 404, GET /api/mcp/message → 404, junk-segment 404, and a Bearer initialize regression. Server-side only — no public page, sitemap, OpenAPI, nav, or tool-count change; the freightutils-mcp npm package is untouched.
May 2026
Continuous proof that a clean MCP client can connect, authenticate, and invoke tools — so the first time it cannot, for any user, an alert fires. New GET /api/mcp/health (public, 60s edge cache, rate-limit exempt) returns status / mcp_version / tools_registered (19) / transports / data_freshness / timestamp; callable by a customer agent for self-diagnostic without burning anonymous budget. New scripts/smoke/mcp-customer-experience.mjs simulates a brand-new user end-to-end against both transports — remote streamable-HTTP runs handshake + tools/list + four tool_calls with golden-value asserts (cbm_calculator 0.96 m³, adr_lookup UN 1203 class 3, airline_lookup AWB 176 → Emirates, shipment_summary non-empty); stdio spawns npx -y freightutils-mcp@latest and verifies handshake + 19 tools registered. Wired into .github/workflows/mcp-smoke.yml — daily 06:15 UTC cron, push-to-main on MCP paths, workflow_dispatch trigger, captures a Sentry event via sentry-cli on failure. Sentry MCP-specific failure tagging now lives in lib/observability/audit.ts: every MCP request sets scope tags surface=mcp / transport=remote / stage / tool_name; explicit captureException fires on handler throws, captureMessage on 5xx and 401. PII-safe — only the existing apiKeyTail (last 8 chars) carries customer attribution. /api-docs#mcp-setup gains a First-time setup verification block with Claude Desktop / Cursor / Cline configs, the "fully quit and relaunch" instruction, the health-endpoint curl, and a 5-row troubleshooting table. Two real bugs surfaced during development (separate sprints to fix): the standalone freightutils-mcp npm package does not pass an API key through its proxied HTTP calls (so stdio users hit the 25/day anon cap regardless of Pro status); and the MCP cbm_calculator response is still in camelCase while the REST /api/cbm equivalent is snake_case. Smoke handles both bugs gracefully — stdio tool_call leg skipped via MCP_SKIP_STDIO_CALLS=1 until the package fix ships; cbm assert accepts either casing.
New /methodology page (629 words) lists every shipped dataset with its named authoritative source, update cadence, and last audit date — HS (WCO 2022), ADR 2025 (UNECE), Airlines (IATA/ICAO), UN/LOCODE (UNECE), Containers (ISO 668 + carrier specs), Vehicles (EU 96/53/EC + UK DVSA + manufacturer specs), INCOTERMS (ICC 2020), ULDs (IATA ULD Technical Manual), UK Duty (HMRC Trade Tariff). Documents the two-agent verification chain (Code transcribes; Chrome re-reads the live source URL; smoke tests + regression guards catch systematic errors) and the per-record provenance.decision_rationale audit trail. Names the limitations plainly (not a substitute for a DGSA, not customs-broker advice, real-world tare varies between build lots). Error-report path: email or GitHub issue, investigated within 48h, fixed entries logged here by date. /about page rewritten end-to-end (462 words) into first-person practitioner voice — names Soap, a UK ADR-certified freight transport planner working live air cargo operations at Heathrow, after years in freight operations planning; What-this-isn't section plainly disclaims VC-funded / chatbot wrapper / canonical-source framings. A new <CredibilityLine /> component is wired above the input on all 7 ADR tool pages (/adr, /adr-calculator, /adr/lq-eq-checker, /adr/limited-quantities, /adr/tunnel-codes, /adr/training-guide, /adr/changes-2025) and all 8 non-ADR tool pages (/hs, /airlines, /containers, /uld, /unlocode, /incoterms, /vehicles, /duty). Footer Platform column gains a Methodology link. /about is also in the main nav. Voice + endorsement scan clean (no founder-marketing tells; no false UN/IATA/ICC endorsement claims).
Google Search Console (2026-05-25 alert) flagged the new "Excluded by noindex tag" indexing reason on pages submitted via sitemap. Audit confirmed four overlap URLs — /privacy, /terms, /refund-policy, /dpa — all carry robots: noindex, follow in their metadata export (added 2026-04-30 in the legal/compliance audit commit e169a5b) but were still being emitted from app/sitemap.ts. Removed the four entries from staticRoutes and added a NOINDEX_PATHS ReadonlySet at the top of app/sitemap.ts that drives a final-stage filter on the assembled sitemap as a safety net — covers /privacy /terms /refund-policy /dpa /account /signin /login /dashboard so a future drive-by re-add of any of these still gets caught before publication. Sitemap URL count drops 9,830 → 9,826. No unintentional noindex found — all noindex pages are legitimately user-state, legal, or the /hs/code/00000000 honeypot (already excluded at its generation site). The "Blocked due to other 4xx issue" GSC reason was also audited: no URLs in the current sitemap return 4xx for valid inputs and next.config.ts redirects cover the known legacy URLs Google may still have indexed (/privacy-policy → /privacy, /tos → /terms, /data-processing-agreement → /dpa, etc.). Post-deploy IndexNow ping for the four removed URLs accelerates the deindex.
Smithery scrape verification on 2026-05-24 caught the website MCP transport advertising serverInfo.version "1.0.8" via initialize even though SITE_STATS.mcpVersion and npm both read 2.1.1. The hardcoded literal in app/api/mcp/[transport]/route.ts is replaced with SITE_STATS.mcpVersion so the wire identity tracks the same constant the rest of the site uses (/api/health, changelog, audit logs). Two drift surfaces collapsed into one; the deeper "compute from freightutils-mcp/package.json or npm registry at build time" automation stays queued in Notion. Companion docs: root README.md replaced (was stock Next.js boilerplate) with a proper surfaces table + dev quickstart + related-repos table; SoapyRED/freightutils-mcp README updated — uld_lookup count line 15 → 16, changelog backfilled with 1.1.0 / 2.0.0 / 2.1.0 / 2.1.1 entries (previously stopped at 1.0.8). Next Smithery + Glama re-scrape will pick up both surfaces.
Three small drift fixes. (1) Sitewide RSS auto-discovery — added <link rel="alternate" type="application/rss+xml" title="FreightUtils Changelog" href="/changelog.xml" /> to the root layout <head> in app/layout.tsx so RSS readers like Feedly and NetNewsWire pick up the feed from any page on the site (previously only /changelog itself advertised the feed via per-page metadata). Footer Platform column gains an explicit "RSS Feed" link to /changelog.xml for human discovery alongside the existing Changelog entry. The feed itself — 50-entry RSS 2.0 generated from lib/changelog-data.ts via app/changelog.xml/route.ts — was already live; this PR closes the discoverability gap. (2) /uld page meta description and OG image generator URL still hardcoded "15+ unit load device types" while the underlying lib/data/ulds.json is 16 entries since PR #25 (2026-05-13 rebuild) and the on-page chip already reads ${ULD_COUNT} dynamically. Bumped both hardcoded strings to "16" in app/uld/page.tsx so SERP snippets and social cards match the on-page chip and /api/uld meta.total. Reconciliation report — all other surfaces already consistent: SITE_STATS has no ULD count field; /about, /, /api-docs do not mention a count; root README.md is the stock Next.js template; no lib/api-tools-registry file exists. (3) SITE_STATS.mcpVersion in lib/constants/siteStats.ts bumped 1.0.5 → 2.1.1 to match the live freightutils-mcp npm publish; used in the /api/health response only — string stringify, no shape change. Added a TODO comment beside the constant to compute from freightutils-mcp/package.json or fetch from the npm registry at build time (queued in Notion idea vault as the staleness-automation thread; not in this PR scope).
/contact reworked to fix the "same email N times" repetition that read as amateurish. Five topic cards (general, Pro support, data corrections, security, abuse) keep their titles and descriptions, drop the per-card visible email line, and gain a single orange "Email us →" CTA with a topic-specific mailto subject. The raw contact@freightutils.com address now appears exactly once, in a dashed block below the card grid. Cards also gain category-coloured left borders (general = accent, Pro = customs blue, data = ref purple, security + abuse = DG red) so they match the rest of the site's card pattern. The Pro card optionally renders a secondary "Book a 15-min call →" button when NEXT_PUBLIC_CALENDLY_URL is set in the Vercel environment — unset by default, so flipping it on is a config-only change with no redeploy logic needed. The resolver lives at lib/contact/calendly.ts and the conditional contract is pinned by scripts/test-contact-calendly.mjs (11 assertions covering undefined / null / empty / whitespace / trimming / truthy paths). Separately: welcome email 2 ("3 things to try") now leads with the API Playground — paste your key, hit Send, see live responses with no terminal needed. The MCP-server item was dropped (covered well enough by /api-docs#mcp and the homepage MCP card); shipment-summary (now #2) and ADR lookup (now #3) stay. Subject line unchanged so the existing drip-scheduler state in KV keeps working.
/playground is now live as a single interactive surface for every endpoint in the OpenAPI spec. Pulls the endpoint list from /openapi.json at runtime; default inputs live in lib/playground/endpoint-examples.ts and are parity-enforced by scripts/test-playground-defaults.mjs (adding a new endpoint without an example breaks CI). Cards are grouped by the four design-system categories with the v1 solid-left-border pattern; response panel uses the dashed informational variant. A single API-key field at the top is gated by an explicit "Remember this key in my browser" checkbox — untick to clear, tick to persist to localStorage under fu-playground-key. The key is sent only as Authorization: Bearer on the actual API call — never logged, never in analytics, never in error reports. Displayed key in curl/JS/Python tabs is redacted to fu_live_***LAST8 unless Show full key is toggled (same last-8 convention as the apiKeyTail Sentry tag). Response panel surfaces status, latency, pretty-printed JSON, and inline upgrade prompts on 401/403/429. Deep-linkable cards (/playground#get-api-cbm) auto-expand on landing. /api-docs gets a prominent Open Playground CTA at the top plus per-endpoint "Try it →" links on all 18 endpoint headers. Wired into sitemap, nav Platform dropdown, footer Developers column, and the homepage For Developers CTA cluster. Tests: scripts/test-playground-defaults.mjs (defaults parity) and scripts/test-playground-key-persistence.mjs (localStorage tick/untick/reload).
Companion to the recursion fix (#51). New reconciliation backstop at lib/auth/reconcile.ts wired into /api/auth/me and /api/auth/whoami: when KV reads plan: free and the user has a non-null stripeCustomerId, the endpoint asks Stripe whether the customer actually has an active subscription and self-heals if so. Cooldown-gated 1h per email so identity-page refreshes do not hammer Stripe. Emits a tier_mismatch_reconciled Sentry warning on every heal — that is the alert signal that a primary webhook path failed silently again. /api/auth/whoami only invokes the backstop on the plan === free slow path so Pro keys keep the one-KV-read fast path. New manual override at scripts/admin/upgrade-user.mjs — Stripe-gated one-shot for KV writes, refuses to upgrade an unpaid account, reads back to verify both user:<email> and key:<apiKey> records. Triage runbook at docs/runbooks/customer-tier-sync.md. Phase-1 diagnosis archived at docs/audit/customer-pro-tier-sync-2026-05-21.md. FAULT 16 encoded in docs/FAULT-HISTORY-AND-PREVENTION.md.
Two observability gaps closed in response to the Saytz incident. (1) Sentry only had has_api_key:true as a boolean — no way to filter errors to a specific paying customer. The audit pipeline at lib/observability/audit.ts now sets an apiKeyTail Sentry tag with the last 8 chars of the request key (Authorization Bearer or x-api-key). Last 8 only, no fu_live_ prefix — so the message redactor at lib/sentry-redact.ts (which does not walk event.tags) leaves the value alone. The tag is OMITTED entirely for anonymous requests so Sentry does not index empty values. (2) No abandonment detection — Saytz had 4 calls on day 1 and zero since, and we only learned via the customer email. New Vercel cron at /api/cron/customer-health (daily 09:00 UTC) scans KV user:*, sums the last 7 daily-bucket counters for each Pro customer with daysSinceSignup >= 3, and emails a digest to contact@freightutils.com when any total is below 10. Manual mute via a silenced_until ISO field on the KV record. Logic is dependency-injected so the unit test exercises the full branching without hitting prod KV or sending real emails (31 assertions across 7 scenarios). Sentry alert rules to be applied via the dashboard — spec in the PR body. Runbook: docs/runbooks/customer-health-cron.md.
safeUpdateUserPlan in app/api/stripe/webhook/route.ts called itself recursively instead of the imported updateUserPlan. Every checkout.session.completed / customer.subscription.deleted event silently stack-overflowed; the surrounding try/catch ate the RangeError and the webhook still ACK'd 200 so Stripe never retried — 100% silent failure for ~24h on the Sytze upgrade. Replaced with a wrapper in lib/auth/safe-update-user-plan.ts that delegates to updateUserPlan, captures any exception to Sentry with source=stripe-webhook + stripe_event_type + email + plan tags, and re-throws so the route returns 500 and Stripe applies its standard retry backoff. Silent-eat is exactly what made the failure invisible. Added stripeSubscriptionId as an optional field on the User type in lib/auth/kv.ts and extended updateUserPlan to persist it (plus a plan: null sync-only path for transient Stripe statuses like past_due where downgrading is incorrect). Webhook now wires customer.subscription.created and customer.subscription.updated to sync subscription.id on every transition; checkout.session.completed extracts session.subscription so the field is populated at upgrade time. Unit tests pin the no-recursion regression and the User-type round-trip.
A paying customer whose KV record had been hand-edited in the Upstash dashboard (manual recovery from a separate webhook issue) triggered 500s on /account and /api/auth/me. The dashboard edit replaced the JSON value instead of merging, dropping the apiKey, email, and createdAt fields. Both call sites did user.apiKey.slice(-4) and threw TypeError: Cannot read properties of undefined. The production release SHA was unchanged through the incident — the failure was purely data-shape, not a deploy regression. Anonymous traffic and every other authenticated user were unaffected. Fix: defensive guards on both surfaces (apiKey and email accessed via nullish-fallback), the masked-key chip on /account now renders "not available — contact support" instead of crashing, both surfaces emit Sentry.captureMessage("malformed_user_record", {level:"warning"}) when a guard fires, and a new "needs attention" banner on /account tells the customer their subscription is intact while support restores the record. FAULT 17 encoded. Diagnosis at docs/incidents/2026-05-21-prod-homepage-500.md.
Free-tier signups (the top of the API funnel) have been silent on prod — Stripe-side notifications cover paid signups, but free-tier API key issuance fired no internal alert. New helper lib/email/admin-signup.ts sends a transactional email via the existing Resend integration with subject "[FreightUtils] New free signup: {email}" or "[FreightUtils] New Pro signup: {email}" to contact@freightutils.com (override via ADMIN_NOTIFICATION_EMAIL env, e.g. mcristoiu@gmail.com for direct-to-personal). Body carries: email, tier, UTC + UK timestamps, country (x-vercel-ip-country for free flows; Stripe address.country for Pro), referer (free flows only — Stripe webhook is server-to-server), source (legacy-form / magic-link-verify / stripe-checkout), and the optional use_case for legacy-form signups. No PII beyond email; no payment details, no Stripe IDs, no API key values. Three call sites wired: app/api/keys/register/route.ts (after KV insert), app/api/auth/verify/route.ts (with a pre-createUser getUser check so notifications only fire for genuine first-time signups, not returning sign-ins), and app/api/stripe/webhook/route.ts on checkout.session.completed. Non-blocking — every site uses `void notify(...).catch(...)` so a Resend or KV outage cannot delay or fail the signup ack. Kill-switch ADMIN_NOTIFICATIONS_ENABLED=false silences without a deploy. Rate-limit cap 30/h via KV bucket admin-signup-notifs:${YYYY-MM-DDTHH}; above cap console.warn and skip. Contract-verified by scripts/admin-signup-notify-test.mjs which checks the helper exports, the kill-switch boolean logic (unset → on; "true" → on; "false" → off; empty → on), every call site's void+.catch wrapping, and the rate-limit constants. Audit: docs/audit/admin-signup-notifications-2026-05-20.md.
siteStats.ts toolCount + mcpToolCount both moved 18 → 19, propagating to homepage / /about / /api-docs. FREIGHTUTILS_STRATEGY.md mcp.directory bullet updated to live v2.1.1 / 19 tools. CLAUDE.md "Distribution cascade" + STATE.md surfaces table amended to encode Chrome-verified Glama cascade: Glama auto-publishes its build artefact on successful scrape-derived build — the "Build succeeded for FreightUtils MCP Server" maintainer email is informational, no "Create a release" click required. Maintenance grade C → B confirmed post-publish. Added a "verify Glama behaviour, do not predict it" one-liner — this was the third cascade drift in three days. Historical "11" / "18" mentions in past changelog entries + audit doc forensic record left intact. External follow-ups (freightutils-mcp README + server.json + package.json descriptions, Glama dashboard description) listed with exact target text in the PR body. Audits: docs/audit/tool-count-standardise-2026-05-17.md (Phase 1 diagnosis); docs/audit/distribution-cascade-2026-05-16.md (2026-05-17 afternoon amendment).
Sprint 1 made the MCP Registry cascade work, but Chrome verification on Glama showed "Latest release: v1.0.0", Tool Definition cache stuck at 11 tools, Maintenance grade C. Diagnosed that Glama scrapes the GitHub Releases API specifically — freightutils-mcp had zero Releases ever cut. Extended the publish workflow with a final step that creates a Release on every tag push (notes pulled from freightutils-mcp CHANGELOG.md between the version heading and the next one; marked --latest=true when the version matches MCP Registry isLatest). Plus an idempotent publish guard so re-runs on already-published versions are no-ops. Backfilled four historical Releases via workflow_dispatch: v2.1.1 (Latest), v2.1.0, v2.0.0, v1.1.0. Glama re-scrape lag is days; Chrome re-verify in 3–5 days. Walkthrough: future v2.1.2 will npm publish → git push --follow-tags → publish to MCP Registry → cut GitHub Release in one workflow run. Companion PR SoapyRED/freightutils-mcp#3 MERGED. Audit amendment: docs/audit/distribution-cascade-2026-05-16.md (2026-05-17 section).
/account, /contact, /docs/{deprecation,versioning}, /dpa, /for-it, /guides, /guides/[slug], /refund-policy, /roadmap, /signin, /status all rendered <title>X — FreightUtils | FreightUtils on prod (the per-page title hardcoded "— FreightUtils" and the root layout template appended "| FreightUtils" too). Removed the per-page brand from each; the root template adds it once. Recovers ~9 SERP characters per affected page. ADR detail titles regained the "ADR 2025" edition signal — they now read "UN 1203 Petrol — ADR 2025 Class 3 PG II" instead of "UN 1203 Petrol — ADR Class 3 PG II", across ~2,347 ADR detail pages. Prevention: scripts/lint-seo-titles.mjs now walks every app/**/page.tsx and asserts no rendered title contains "FreightUtils" more than once (Rule A) + every ADR fixture title includes "ADR 2025" (Rule B); CI-gated via a new prebuild hook so Vercel runs the lint before next build. Search-performance baseline + re-crawl windows in STATE.md "Search performance baseline" section. Diagnosis + repair: docs/audit/title-template-2026-05-16.md. Encoded as FAULT 15.
The Publish to MCP Registry workflow in SoapyRED/freightutils-mcp now fires on tag push (v*) in addition to GitHub Release + manual workflow_dispatch. The version-resolve step reads from input → tag → package.json so catch-up runs work without an explicit input. MCP Registry caught up in-sprint to freightutils-mcp@2.1.1 (isLatest=true confirmed via the public Registry API). Smithery and Glama are scrape-based — they re-fetch independently with days-of-lag, no event link from the Registry; the prior internal docs claim of a Registry → Smithery/Glama cascade was wrong and is corrected. The freightutils-distribution skill plus a new CLAUDE.md "Distribution cascade" section now anchor the actual sync model. Full diagnosis + version-bump runbook at docs/audit/distribution-cascade-2026-05-16.md. awesome-mcp-servers PR #5358 updated to the 19-tool surface; Punkpeye-merge pending.
All 17 records in lib/data/vehicles-ref.json carry per-record provenance (≥2 sources each: EU Directive 96/53/EC, UK gov.uk vehicle weights & licence categories, 49 CFR Part 393 for US trailers, plus manufacturer specs from Schmitz Cargobull / Krone / Faymonville / DAF / Mercedes-Benz / VW / Ford / Wabash National) plus audited_at / verified / decision_rationale — surfaced on /api/vehicles. The customs duty calculator gained a structured DUTY_METHODOLOGY constant documenting the CIF → duty → VAT formula and Trade Tariff measure-type resolution (103 third-country, 142 preferential, 305 VAT, 695 anti-dumping, 277 restrictions), with 8 HMRC source URLs — exposed via GET /api/duty?methodology=true. A 10-code regression fixture (lib/data/duty-sample-fixture.json: 0901, 2204, 6110, 8471, 8703, 3004, 7113, 4011, 9504, 0207) anchors the live Trade Tariff API integration for future smoke-test extension. Sandbox HTTPS allowlist did not include the cited domains this audit pass — every record + the methodology flagged verified: false until live re-fetch. Gap report at docs/audit/vehicles-customs-completeness-2026-05-16.md; verification runbook at docs/audit/vehicles-customs-verification-2026-05-16.md.
New page at /hs/code/00000000 returns a 200 with a clearly-marked placeholder entry ("RESERVED FOR TESTING — NOT A REAL COMMODITY CODE", duty 0.00%); noindex,nofollow keeps honest crawlers out of the index. A hidden bait link (position:absolute; left:-9999px, tabindex=-1, aria-hidden, rel="nofollow") is seeded on /hs so DOM-parsing scrapers follow it. Hits are logged in middleware with the marker `[ScrapeGuard] HONEYPOT path=/hs/code/00000000 ip=… ua=…`, fired before the HS rate-limit check so a trap-trigger is captured even when the IP is over its 10/5min budget. No auto-block this sprint — evidence collection first. Defensive filter on app/sitemap.ts keeps the path out of sitemap.xml.
50 sample HS codes (20 top-traffic, 15 DG-adjacent in chapters 27/28/29/36/85, 10 high-value in 84/87/71/62/22, 5 coverage) now carry per-record sources / audited_at / verified / decision_rationale via a new sample-provenance file merged at getCodeDetails — surfaced on /api/hs?code=…. The other 6,890 records of the 6,940-code HS 2022 dataset are untouched. Full-dataset gap report: 100% per-field populated, 97/99 chapters present (77 + 98 absent by HS-2022 design), 0 orphan parent references. Sandbox HTTPS allowlist did not reach trade-tariff.service.gov.uk this pass; every sample record flagged verified: false until live re-fetch.
All 10 sea-container records carry ISO 6346 codes (22G1, 42G1, 45G1, L5G1, 22R1, 45R1, 22U1, 42U1, 22P1, 42P1) plus per-record sources / audited_at / verified / decision_rationale — surfaced on /api/containers. 30 LHR-weighted UN/LOCODE anchors (GBLHR, USJFK, SGSIN, HKHKG, AEDXB and others) now carry per-record provenance via a new merged anchor-provenance file — surfaced on /api/unlocode?code=…. Sandbox HTTPS allowlist did not reach primary sources this pass, so no field values mutated; every record flagged verified: false until live re-fetch.
Fixed double-branded titles (root template now appends "| FreightUtils" instead of "| FreightUtils.com"; /changelog uses title.absolute). Dark-mode toggle syncs across tabs via the storage event. /api-docs rate-limit copy normalised to "25 requests per day" across all three mentions. NewsletterCapture added to /hs/code/[code] and /hs/heading/[heading] templates so HS detail pages match the ADR-detail email-capture pattern.
Middleware now emits the User-Agent and full client IP on every 429 — never on the success/cache-hit path. Supports evidence-based firewall rule additions. UA is sanitised (control chars stripped to prevent log injection, internal quotes replaced with apostrophes, truncated to 200 chars, falls back to `ua=empty` for null/whitespace). Log line format converted to space-separated `key=value` pairs for grep/awk parsing. IP resolution unchanged.
Scraper-target page routes now served from Vercel edge cache with explicit Cache-Control (s-maxage=86400, SWR=7d). Warm-cache hits skip page render — cold-serve response unchanged. ISR was already enabled on both routes; this surfaces the cache strategy in next.config.ts so it is tunable and visible via curl -I. The middleware-layer ScrapeGuard rate limiter continues to 429 the active 216.* scraper as designed.
Redis errors in the ScrapeGuard middleware now log at most once per minute per error class instead of per request, preventing the Sentry-event flood we saw during Upstash quota exhaustion on 2026-05-14 (~1K events in 30 min). Fail-open behaviour preserved — Redis failures still allow the request through.
25 anchor UN numbers (55 variant rows) now carry per-record `provenance.sources` URLs, `decision_rationale`, and `audited_at` timestamps. `/api/adr` response surfaces all three fields. Full-dataset gap audit at docs/audit/adr-completeness-2026-05-13.md.
Fixes a long-standing data fault where IATA prefix CV was misattributed; prefix index repaired so cargo-only filters now resolve cleanly. 35-airline anchor set gained per-record provenance. Cargolux, Cathay Cargo, Emirates SkyCargo, and others all verified.
7 verified, 9 variant-decision-needed. Magnitude corrections: AKH height 163→114 cm (LD3-45 is narrowbody-only); PLA dims/MGW rebased from PAJ-class to actual P1P (318×153×163, MGW 3175); ALP re-labelled LD7→LD11; RAP rewritten from passive blanket to active LD-9 thermal container (Cargolux/DSV/ACL/Wikipedia all confirm IATA registration); PMC-Q7 tall variant (300 cm) split into a separate entry. New provenance schema with `audited_at`, `chrome_verified_at`, `soap_signoff_at` timestamps. Predecessor PR #24 (PAG/PGA swap fix) consolidated here.
New /signin page and `/api/auth/public-signin` endpoint. Reveals nothing to anonymous callers about which email addresses are registered; `return_to` query parameter whitelisted to FreightUtils paths only. Nav now flips between "Sign in" and "Account" based on session-cookie identity.
Logged-in customers can view subscription status, billing history, and trigger Stripe customer-portal flows. Anonymous visitors to /account redirect to /signin. Session-cookie identity (`/api/identity/whoami`) + customer-portal endpoints land in the same PR.
Full subscription cancellation flow: customer-initiated cancel through Stripe portal, immediate admin email, customer confirmation email with UK Consumer Contracts Regulations 2013 Reg 37 waiver wording (no 14-day right to cancel after services are partly performed at customer request).
April 2026
New /contact page (mailto + form), /dpa page (Data Processing Agreement listing sub-processors: Vercel, Cloudflare, Stripe, Loops, Sentry). robots.txt and redirects audited and tightened in the same commit.
Fixes B029: an empty `X-API-Key` header was previously bucketed as anonymous instead of unauthenticated. n8n/Zapier integrations that test with an empty key during credential setup now correctly see 401 and surface the error to the user.
/api/mcp tool input schemas migrated from camelCase to snake_case to mirror the freightutils-mcp npm package v2.0.0. JSON contract stays consistent across the website MCP surface and the standalone npm/Zapier/n8n integrations.
Added `lib/seo/page-metadata.ts` builders for the four templated page families. Titles now ≤60 chars with the primary search keyword in the first 50; descriptions ≤155 chars with "free" + ("no login" / "updated YYYY"). Permanent `lint:seo-titles` build-time check enforces the pattern. Fixes the US CTR gap (0.07% vs UK 0.94%) flagged by GSC.
Fixes prefix misattributions on ZP and GS, backfills missing AWB prefix data for AV (Avianca) and UK (Air Bridge Cargo Airlines). 5 newly-active cargo airlines flagged in the dataset.
Every `app/api/**/route.ts` now wrapped with `withAuditRest` (or `withAuditMcp` for the MCP transport route). Emits a single structured `[fu-audit]` line per request with path, method, status, tier, latency. Privacy contract: NO request bodies, NO API key values, NO IPs, NO emails ever logged. Build-time `lint:audit` check fails if any new route forgets to wrap.
New endpoint that validates an API key against the registry and returns tier (free/pro) plus the key prefix (never the full key). Replaces /api/health as the credential-test target for n8n and Zapier integrations — fixes B028, where /health returned 200 to any caller and silently green-ticked invalid keys.
Fixes B003: /api/health reported 18 tools while /api/tools listed 17. Both endpoints now derive from a single registry; the missing entry (ADR LQ/EQ Check, shipped 2026-04-09) was added back. Adding a new tool propagates to both endpoints automatically.
Six endpoints migrated from camelCase to snake_case response fields (/api/unlocode, /api/uld, /api/containers, /api/vehicles, /api/consignment, /api/duty). Site-wide consistency with the other thirteen endpoints. Clean break — no dual-output. Full field rename table in CHANGELOG.md.
Five new platform pages plus a `/changelog.xml` RSS feed. Roadmap is GitHub-seeded so public-facing items stay in sync with the internal sprint plan. Versioning and deprecation policy documents codify API stability commitments.
Check Limited Quantity and Excepted Quantity eligibility for mixed dangerous goods consignments. Multi-item input with per-item breakdown, green/red/amber verdicts. POST /api/adr/lq-check.
15 unit load device types — AKE (LD3), PMC, PLA, and more. Dimensions, weights, volume, aircraft compatibility, and deck positions. Free REST API at /api/uld.
17 road freight vehicle and trailer types — curtainsiders, rigids, vans, US 53ft. Dimensions, payload limits, pallet capacity. Free REST API at /api/vehicles.
POST /api/shipment/summary chains CBM, LDM, chargeable weight, ADR compliance, and UK duty estimation into one response. Accepts road/air/sea/multimodal modes.
Consignment calculator and chargeable weight calculator now support sea freight mode with W/M (Weight or Measure) at 1 CBM = 1 revenue tonne.
Canonical FreightUtils Shipment schema defined — the foundational data model for all composite endpoints.
Estimate UK import duty and VAT for any commodity code using live GOV.UK Trade Tariff data. Supports Incoterm-adjusted CIF, preferential rate flagging, and cross-tool HS code workflow.
116,000+ transport locations from UNECE UN/LOCODE 2024-2. Searchable by name, code, country, and function type (port, airport, rail, road, ICD, border).
Calculate total CBM, weight, LDM, and chargeable weight across mixed items. Available as web tool, REST API, and MCP tool.
FreightUtils MCP Server v1.0.1 now on registry.modelcontextprotocol.io. Also listed on Smithery.ai, mcp.so, and 5+ directories.
Connect AI agents to all FreightUtils tools at freightutils.com/api/mcp/mcp. Streamable HTTP transport via mcp-handler.
Courtesy rate limit changed from 1,000/hour to 100 requests per day per IP across all API endpoints. Real enforcement via Vercel KV middleware.
Calculate ADR exemption thresholds for mixed hazardous loads. Supports multi-substance mixed-load calculation.
2,939 dangerous goods entries from UNECE ADR 2025 edition (licensed from Labeline.com).
6,940 codes from WCO HS 2022 nomenclature with full section/chapter/heading hierarchy.
6,352 entries with IATA/ICAO codes and AWB prefixes. Cargo-only default view with 390 cargo airlines.
March 2026
11 free freight tools with open REST APIs. LDM, CBM, chargeable weight, pallet fitting, container capacity, unit converter, ADR, airlines, INCOTERMS, HS codes.
Full OpenAPI spec covering all endpoints. Compatible with Swagger and Postman import.
All 11 Incoterms with seller/buyer responsibilities, risk transfer, cost transfer, and insurance details.
Dates are approximate. Data sourced from UNECE, WCO, ICC, IATA. For corrections or data issues contact contact@freightutils.com.