Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,151 @@
# Design: enterprise-ports
## Module layout
```
packages/geospatial/logistics/ports/
package.json
tsconfig.lib.json
vite.config.ts
vitest.config.ts
README.md
src/
index.ts
constants/index.ts # EARTH_RADIUS_NM, DEFAULT_SPEED_KNOTS, VESSEL_CLASS_SPEEDS
types/
port.types.ts
port.types.md
error-codes/
error-codes.ts
error-codes.md
error-codes.test.ts
index.ts
schemas/
schemas.ts
schemas.md
schemas.test.ts
index.ts
otel/
otel.ts
otel.md
otel.test.ts
index.ts
data/
sea-ports-asia/
sea-ports-europe/
sea-ports-americas/
sea-ports-africa-mena/
sea-ports-oceania/
sea-ports/
airports/
rail-terminals/
all-ports/
shipping-lanes/
distance/
distance.ts # great-circle wrapper, nm/km conversions
distance.md
distance.test.ts
index.ts
find-port/
find-port.ts
find-port.md
find-port.test.ts
index.ts
nearest-port/
nearest-port.ts
nearest-port.md
nearest-port.test.ts
index.ts
spatial-index/
spatial-index.ts
spatial-index.md
spatial-index.test.ts
index.ts
lane-lookup/
lane-lookup.ts
lane-lookup.md
lane-lookup.test.ts
index.ts
lane-distance/
lane-distance.ts
lane-distance.md
lane-distance.test.ts
index.ts
lane-progress/
lane-progress.ts
lane-progress.md
lane-progress.test.ts
index.ts
eta/
eta.ts
eta.md
eta.test.ts
index.ts
property.test.ts
```
## Distance helper
`distanceNm(a, b)` and `distanceKm(a, b)` wrap
`@suaveplan/geo`'s `greatCircleDistance` against a WGS84-radius
spherical earth (semi-major axis 6_371_000 m) via `defineEllipsoid`. The
genesis `greatCircleDistance` returns metres by default; this package
converts via the geo `convertDistance` utility. We bind a single
`EARTH_SPHERE` ellipsoid once at module load — no allocation per call.
## Error mapping
Each main-branch `new PortError("msg", "CODE")` call maps to a typed
subclass:
| main code | genesis class |
|---|---|
| `NO_PORTS` | `PortsNoPortsError` |
| `INVALID_RADIUS` | `PortsInvalidRadiusError` |
| `INSUFFICIENT_WAYPOINTS` | `PortsInsufficientWaypointsError` |
| `INVALID_SPEED` | `PortsInvalidSpeedError` |
| `MISSING_SPEED` | `PortsMissingSpeedError` |
| _(new)_ | `PortsPortNotFoundError` — strict variant of `findPort` |
| _(new)_ | `PortsLaneNotFoundError` — strict variant of `getShippingLane` |
| _(new)_ | `PortsInvalidLaneError` — Zod schema rejected |
All extend `PortsError extends SuaveplanError<PortsCode>` with i18n
message keys.
## Spatial index
`PortSpatialIndex` keys ports by `floor(latitude / 5)` and
`floor(longitude / 5)` into a `Map<string, Port[]>`. Query for a
coordinate fetches the 3×3 cell neighbourhood and runs exact great-circle
distance on the candidate set. For ~120 curated ports this is overkill in
absolute terms but keeps the public API stable as the dataset grows.
## OTel instruments
Module-scope meter `@suaveplan/ports@0.1.0`:
- Counter `ports.lookup_total` — labels `kind=findPort|byCountry|byType|search`.
- Counter `ports.lane_lookup_total` — labels `mode=sea|rail|air|all`.
- Counter `ports.eta_total` — labels `confidence=high|medium|low`.
- Histogram `ports.find_nearest_duration_ms`.
- Histogram `ports.lane_distance_duration_ms`.
- Histogram `ports.eta_duration_ms`.
`refreshPortsMeters()` re-resolves every instrument against the current
global `MeterProvider` after tests wire in-memory exporters.
## Clock injection
`estimateETA(coord, lane, speedKnotsOverride?, opts?)`:
`opts = { departureTime?: Date; clock?: Clock }`. If `departureTime` is
omitted and `clock` is supplied → `new Date(clock.now())`; else `new
Date()`. Tests pass a `FakeClock` from `@suaveplan/testing` for
determinism.
## Data shape stability
The migrated dataset preserves every record from the main branch
(coordinates, throughputs, vessel limits, IATA/UIC codes) — adding any
new fields would break consumer expectations. The data is split into
per-region files exactly mirroring the main layout so future curated
updates can be diffed cleanly.

View File

@@ -0,0 +1,105 @@
# OpenSpec Change: enterprise-ports
- **Change ID**: enterprise-ports
- **Package**: `@suaveplan/ports`
- **Category / plugin**: `geospatial` / `logistics`
- **Target path**: `packages/geospatial/logistics/ports/`
- **Status**: PROPOSED
- **Date**: 2026-05-25
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/ports.md`
- **Main-branch source**: `/mnt/s/web/suaveplan/packages/universal/ports`
---
## Why
`@suaveplan/ports` is the canonical universal-tier global port database
on the main branch. It pairs a curated dataset of sea ports, airports,
and rail terminals (UN/LOCODE addressed) with shipping-lane waypoint
graphs and ETA estimation. The genesis branch starts empty; landing this
package unblocks every logistics/shipping consumer (`port-tracking`,
`logistics`, `customs`, `procurement`, `warehouse-ops`, and the matching
`api-plugin-*` packages) which all transitively depend on port lookups
and lane-based routing.
The reference implementation on main shipped at 95%+ coverage but
without the cross-cutting concerns mandated by the enterprise checklist
in `enterprise/ports.md`: stable typed error codes via
`@suaveplan/error`, Zod schemas at the public boundary, OpenTelemetry
instrumentation, injectable `Clock`, and 100% test coverage. This change
rebuilds the package from scratch on genesis with those additions while
preserving the full main-branch public surface so direct consumers do
not need migration shims.
## What changes
### Preserved (from main-branch surface)
- Curated port dataset split by region: `seaPortsAsia`, `seaPortsEurope`,
`seaPortsAmericas`, `seaPortsAfricaMena`, `seaPortsOceania`, aggregated
into `seaPorts`; plus `airports` and `railTerminals`. `allPorts`
combines every category.
- Curated `shippingLanes` waypoint graph spanning the major global routes
(Asia–Europe via Suez, Asia–Europe via Cape, Transpacific, Transatlantic,
rail and air lanes).
- Port lookups: `findPort(locode)`, `findPortsByCountry(country, ports?)`,
`findPortsByType(type, ports?)`, `searchPorts(query, ports?)`.
- Spatial lookups: `findNearestPort(coord, ports?)`,
`findPortsInRadius(coord, radiusKm, ports?)`.
- Lane lookups: `getShippingLane(id)`, `getShippingLanesByMode(mode, lanes?)`,
`getAllShippingLanes()`.
- Lane distance: `estimateLaneDistance(lane)`, `cumulativeDistances(lane)`
(nautical miles, great-circle).
- Lane progress: `findPositionOnLane(coord, lane)` →
`{ progress, remainingDistanceNm, distanceTravelledNm, nearestWaypointIndex }`.
- ETA: `estimateETA(currentCoord, lane, avgSpeedKnots?, departureTime?)` →
`{ remainingDistanceNm, etaHours, etaDate, confidence }`.
- Types: `Port`, `PortType`, `PortFacility`, `LaneMode`, `ShippingLane`,
`LaneProgress`, `ETAEstimate`, `NearestPortResult`.
### Added (genesis enterprise additions)
1. **Typed error registry** — `PORTS_CODES` via `defineCodes("PORTS",
["NO_PORTS", "INVALID_RADIUS", "INSUFFICIENT_WAYPOINTS",
"INVALID_SPEED", "MISSING_SPEED", "INVALID_LANE", "PORT_NOT_FOUND",
"LANE_NOT_FOUND"])`. Each code surfaces a dedicated
`PortsError` subclass extending `SuaveplanError`.
2. **Zod schemas** for `Port`, `ShippingLane`, `SphericalCoord`,
`LaneProgress`, `ETAEstimate`, `NearestPortResult` exposed under
`./schemas/schemas` for boundary validation by API consumers.
3. **Injectable `Clock`** — `estimateETA(coord, lane, speed?, opts?)`
now accepts `{ departureTime?, clock? }` so callers (jobs,
simulation, tests) can supply a deterministic clock. `Date.now()` is
never read at module scope.
4. **Vessel-class speed defaults** — `VESSEL_CLASS_SPEEDS` constants for
`ulcv`, `panamax`, `feeder`, `coastal`, `freighter-air`, `rail-freight`
used as fallbacks when neither the lane nor caller specifies a speed.
5. **OTel instrumentation** — counters `ports.lookup_total{kind}`,
`ports.lane_lookup_total{mode}`, `ports.eta_total{confidence}`; histograms
`ports.find_nearest_duration_ms`, `ports.lane_distance_duration_ms`,
`ports.eta_duration_ms`. `refreshPortsMeters()` for test isolation.
6. **Spatial index** — `PortSpatialIndex` bucketed by 5-degree lat/lng
cells, providing O(neighbours) `findNearest`/`findInRadius` instead of
scanning all ~120 ports. Used internally by the public functions.
7. **Property-based tests** — fast-check invariants for ETA monotonicity
(speed↑ ⇒ ETA earlier), distance triangle inequality on great-circle
segments, and radius-containment consistency (`findPortsInRadius`
never returns a port whose haversine distance exceeds `radiusKm`).
### Removed
Nothing. The previous proposal's `PortDatabase` class and JSON-backed
"6000-port" dataset are not part of the main-branch surface and were
aspirational; this change ships the actual surface main shipped.
## Impact
- **Affected specs**: new `ports` capability spec.
- **Affected code**: new package at `packages/geospatial/logistics/ports/`.
- **Downstream**: unblocks `port-tracking`, `logistics`, `customs`,
`procurement`, `warehouse-ops`, and their `api-plugin-*` companions.
- **Manifest**: `MANIFEST.md` regenerated; `STATUS.md` flips `–` → `✅`.
- **Category mapping**: adds plugin `logistics` to super-category
`geospatial` in `scripts/folder-reorg/assignment.yml` (`logistics` was
previously a plugin without a category assignment). The 18 closed
super-categories are unchanged.

View File

@@ -0,0 +1,186 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: ports
**Package**: `@suaveplan/ports`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Port lookup by UN/LOCODE
`findPort(locode)` MUST return the `Port` whose `locode` matches the input (case-insensitive) from the built-in `allPorts` registry, or `undefined` when no match exists. A strict variant `findPortStrict(locode)` MUST throw `PortsPortNotFoundError` (`PORTS.PORT_NOT_FOUND`) on miss. Both MUST emit the OTel counter `ports.lookup_total{kind="findPort"}` on every invocation.
#### Scenario: case-insensitive hit returns the curated record
- **WHEN** `findPort("cnsha")` is invoked
- **THEN** the result has `locode === "CNSHA"`, `country === "CN"`, and `type === "sea"`
**Verified by:** `src/find-port/find-port.test.ts::"finds CNSHA via case-insensitive lookup"`
#### Scenario: strict variant throws on miss
- **WHEN** `findPortStrict("XXNON")` is invoked
- **THEN** a `PortsPortNotFoundError` with code `PORTS.PORT_NOT_FOUND` is thrown
**Verified by:** `src/find-port/find-port.test.ts::"strict variant throws on miss"`
---
### Requirement: Country and type filters
`findPortsByCountry(country, ports?)` MUST return every port whose `country` matches the ISO 3166-1 alpha-2 input (uppercased). `findPortsByType(type, ports?)` MUST filter by `PortType` (`sea | air | rail | multimodal`). `searchPorts(query, ports?)` MUST return every port whose `name` contains the query as a case-insensitive substring. Each function MUST default `ports` to the curated `allPorts` registry.
#### Scenario: country filter excludes other countries
- **WHEN** `findPortsByCountry("nl")` is invoked
- **THEN** every returned port has `country === "NL"` and Rotterdam (`NLRTM`) is present
**Verified by:** `src/find-port/find-port.test.ts::"filters by country"`
---
### Requirement: Nearest port with great-circle distance
`findNearestPort(coord, ports?)` MUST return `{ port, distanceKm }` where `port` is the closest port to `coord` by great-circle distance and `distanceKm` is rounded to two decimals. It MUST throw `PortsNoPortsError` (`PORTS.NO_PORTS`) when the input port array is empty. Internally it MUST consult a `PortSpatialIndex` keyed by 5° lat/lng buckets when the input is the default `allPorts` registry, and emit the OTel histogram `ports.find_nearest_duration_ms`.
#### Scenario: nearest sea port to Singapore Strait
- **WHEN** `findNearestPort({ latitude: 1.26, longitude: 103.82 })` is invoked
- **THEN** the returned `port.locode === "SGSIN"` and `distanceKm < 50`
**Verified by:** `src/nearest-port/nearest-port.test.ts::"nearest port to Singapore Strait is SGSIN"`
#### Scenario: empty registry rejects
- **WHEN** `findNearestPort(coord, [])` is invoked
- **THEN** a `PortsNoPortsError` with code `PORTS.NO_PORTS` is thrown
**Verified by:** `src/nearest-port/nearest-port.test.ts::"throws on empty registry"`
---
### Requirement: Radius search
`findPortsInRadius(coord, radiusKm, ports?)` MUST return every port within `radiusKm` great-circle kilometres of `coord`, sorted ascending by `distanceKm`. It MUST throw `PortsInvalidRadiusError` (`PORTS.INVALID_RADIUS`) when `radiusKm <= 0`. Containment MUST be inclusive of the boundary (distance equal to `radiusKm` is included).
#### Scenario: zero radius rejected
- **WHEN** `findPortsInRadius(coord, 0)` is invoked
- **THEN** a `PortsInvalidRadiusError` is thrown with code `PORTS.INVALID_RADIUS`
**Verified by:** `src/nearest-port/nearest-port.test.ts::"throws on zero radius"`
---
### Requirement: Shipping-lane registry
`getAllShippingLanes()` MUST return the curated `shippingLanes` registry. `getShippingLane(id)` MUST return the lane with matching `id` or `undefined`; `getShippingLaneStrict(id)` MUST throw `PortsLaneNotFoundError` (`PORTS.LANE_NOT_FOUND`) on miss. `getShippingLanesByMode(mode, lanes?)` MUST filter by `LaneMode` (`sea | rail | air`). All lookups MUST emit the OTel counter `ports.lane_lookup_total{mode}`.
#### Scenario: sea-mode filter excludes rail and air lanes
- **WHEN** `getShippingLanesByMode("sea")` is invoked
- **THEN** every returned lane has `mode === "sea"`
**Verified by:** `src/lane-lookup/lane-lookup.test.ts::"filters by sea mode"`
---
### Requirement: Lane distance via great-circle waypoints
`estimateLaneDistance(lane)` MUST sum the great-circle distance (nautical miles) between consecutive waypoints in `lane.waypoints`, rounded to two decimals. `cumulativeDistances(lane)` MUST return an array of length `waypoints.length` whose entry `i` is the cumulative nautical-mile distance from `waypoints[0]` to `waypoints[i]` (so entry `0` is `0`). Both MUST throw `PortsInsufficientWaypointsError` (`PORTS.INSUFFICIENT_WAYPOINTS`) when `waypoints.length < 2`.
#### Scenario: insufficient waypoints rejected
- **WHEN** `estimateLaneDistance({ ...lane, waypoints: [waypoints[0]] })` is invoked
- **THEN** a `PortsInsufficientWaypointsError` is thrown
**Verified by:** `src/lane-distance/lane-distance.test.ts::"throws on insufficient waypoints"`
#### Scenario: cumulative distances are monotonically non-decreasing
- **WHEN** `cumulativeDistances(lane)` is invoked
- **THEN** for every consecutive pair `(d[i], d[i+1])`, `d[i+1] >= d[i]` and `d[0] === 0`
**Verified by:** `src/lane-distance/lane-distance.test.ts::"cumulative distances are monotonically non-decreasing"`
---
### Requirement: Project coordinate onto lane
`findPositionOnLane(coord, lane)` MUST locate the nearest waypoint to `coord` and return `{ progress, remainingDistanceNm, distanceTravelledNm, nearestWaypointIndex }`. `progress` MUST be clamped to `[0, 1]` and rounded to four decimals. `remainingDistanceNm` and `distanceTravelledNm` MUST be rounded to two decimals and clamped to `>= 0`. It MUST throw `PortsInsufficientWaypointsError` (`PORTS.INSUFFICIENT_WAYPOINTS`) when `waypoints.length < 2`.
#### Scenario: position exactly on the first waypoint yields zero progress
- **WHEN** `findPositionOnLane(lane.waypoints[0], lane)` is invoked
- **THEN** the result has `nearestWaypointIndex === 0`, `distanceTravelledNm === 0`, and `progress === 0`
**Verified by:** `src/lane-progress/lane-progress.test.ts::"starting position yields zero progress"`
---
### Requirement: ETA estimation with injectable clock
`estimateETA(currentCoord, lane, avgSpeedKnots?, opts?)` MUST resolve the effective speed from (in order) the explicit override, `lane.typicalSpeedKnots`, `lane.typicalSpeedKmh / 1.852`. It MUST throw `PortsInvalidSpeedError` (`PORTS.INVALID_SPEED`) when the resolved speed is non-positive and `PortsMissingSpeedError` (`PORTS.MISSING_SPEED`) when no speed source resolves. `etaDate` MUST be computed as `(opts.departureTime ?? new Date(opts.clock?.now() ?? Date.now())) + etaHours hours`. `etaHours` MUST be rounded to two decimals. Confidence MUST be `high | medium | low` derived from deviation between travelled+remaining and total lane distance. The function MUST emit the OTel counter `ports.eta_total{confidence}` and histogram `ports.eta_duration_ms`.
#### Scenario: ETA on a sea lane uses lane default speed
- **WHEN** `estimateETA(lane.waypoints[0], lane)` is invoked with a lane that has `typicalSpeedKnots: 14`
- **THEN** the returned `etaHours > 0` and `confidence === "high"`
**Verified by:** `src/eta/eta.test.ts::"estimates ETA using lane default speed"`
#### Scenario: injectable clock controls departure time
- **WHEN** `estimateETA(coord, lane, 14, { clock: { now: () => 0 } })` is invoked
- **THEN** the returned `etaDate.getTime() === etaHours * 3_600_000`
**Verified by:** `src/eta/eta.test.ts::"injectable clock controls departure time"`
#### Scenario: missing speed sources reject
- **WHEN** `estimateETA(coord, { ...lane, typicalSpeedKnots: undefined, typicalSpeedKmh: undefined })` is invoked without an override
- **THEN** a `PortsMissingSpeedError` with code `PORTS.MISSING_SPEED` is thrown
**Verified by:** `src/eta/eta.test.ts::"throws PORTS.MISSING_SPEED when no speed source resolves"`
---
### Requirement: Stable typed error registry
The package MUST export `PORTS_CODES` registered via `defineCodes("PORTS", [...])` covering at least: `NO_PORTS`, `INVALID_RADIUS`, `INSUFFICIENT_WAYPOINTS`, `INVALID_SPEED`, `MISSING_SPEED`, `PORT_NOT_FOUND`, `LANE_NOT_FOUND`, `INVALID_LANE`. Every error thrown by a public function MUST be a `PortsError` subclass whose `.code` matches exactly one entry of the registry.
#### Scenario: every code has a registered subclass
- **WHEN** the suite enumerates `PORTS_CODES`
- **THEN** for each code, a constructable `PortsError` subclass exists whose default `.code` equals that code
**Verified by:** `src/error-codes/error-codes.test.ts::"every PORTS code has a registered subclass"`
---
### Requirement: Zod schemas for public types
The package MUST export Zod schemas (`PortSchema`, `ShippingLaneSchema`, `SphericalCoordSchema`, `LaneProgressSchema`, `ETAEstimateSchema`, `NearestPortResultSchema`) at `./schemas/schemas`. Each schema MUST validate a known good fixture and reject a malformed input.
#### Scenario: PortSchema accepts a curated record
- **WHEN** `PortSchema.parse(seaPortsAsia[0])` is invoked
- **THEN** parsing succeeds without throwing
**Verified by:** `src/schemas/schemas.test.ts::"PortSchema accepts a curated record"`
---
### Requirement: OpenTelemetry instrumentation surface
The package MUST register three counters (`ports.lookup_total`, `ports.lane_lookup_total`, `ports.eta_total`) and three histograms (`ports.find_nearest_duration_ms`, `ports.lane_distance_duration_ms`, `ports.eta_duration_ms`) against the meter `@suaveplan/ports`. `refreshPortsMeters()` MUST re-resolve every instrument against the current global `MeterProvider`.
#### Scenario: refreshPortsMeters rebinds after provider change
- **WHEN** `refreshPortsMeters()` is invoked after setting a new global `MeterProvider`
- **THEN** subsequent counter/histogram emissions are observed by the new provider
**Verified by:** `src/otel/otel.test.ts::"refreshPortsMeters rebinds after provider change"`

View File

@@ -0,0 +1,62 @@
# Tasks: enterprise-ports
**Change ID**: enterprise-ports
**Package**: `@suaveplan/ports`
**Target path**: `packages/geospatial/logistics/ports/`
**Prerequisite packages**: `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/geo`, `@suaveplan/testing`
## 0. Pre-flight
- [x] 0.1 Worktree audit (`git worktree list`, `git status`) clean.
- [x] 0.2 §0a Sync — fast-forward to `genesis` HEAD (`ffe254a1`).
- [x] 0.3 Confirm `logistics` plugin added to `geospatial` super-category in `scripts/folder-reorg/assignment.yml`.
## 1. Scaffold
- [x] 1.1 Create `packages/geospatial/logistics/ports/` with `src/` tree per `design.md`.
- [x] 1.2 Write `package.json` at `0.1.0` with `"type":"module"`, `"sideEffects":false`, subpath exports, deps `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/geo`, `@opentelemetry/api`, `zod`, `tslib`.
- [x] 1.3 `tsconfig.lib.json` extending `../../../../tsconfig.base.json`.
- [x] 1.4 `vite.config.ts` with multi-entry library build.
- [x] 1.5 `vitest.config.ts` via `createTestConfig` with 100% coverage thresholds.
- [x] 1.6 Scripts: `build`, `lint`, `test`, `test:coverage`, `typecheck`.
## 2. Types + errors + schemas + OTel + constants
- [x] 2.1 `src/types/port.types.ts` — `Port`, `PortType`, `PortFacility`, `LaneMode`, `ShippingLane`, `LaneProgress`, `ETAEstimate`, `NearestPortResult`, `SphericalCoord` (re-exported from geo).
- [x] 2.2 `src/error-codes/error-codes.ts` — `PORTS_CODES` (8 codes) + 8 `PortsError` subclasses.
- [x] 2.3 `src/schemas/schemas.ts` — Zod schemas for every public type.
- [x] 2.4 `src/otel/otel.ts` — 3 counters + 3 histograms + `refreshPortsMeters`.
- [x] 2.5 `src/constants/index.ts` — `EARTH_RADIUS_NM`, `DEFAULT_SPEED_KNOTS`, `VESSEL_CLASS_SPEEDS`, `EARTH_SPHERE` ellipsoid.
## 3. Data
- [x] 3.1 Per-region sea-port files (`sea-ports-asia`, `-europe`, `-americas`, `-africa-mena`, `-oceania`).
- [x] 3.2 `sea-ports/sea-ports.ts` aggregating all regions.
- [x] 3.3 `airports/airports.ts`, `rail-terminals/rail-terminals.ts`.
- [x] 3.4 `all-ports/all-ports.ts` combining sea + air + rail.
- [x] 3.5 `shipping-lanes/shipping-lanes.ts` with curated lanes.
## 4. Modules
- [x] 4.1 `distance` — `distanceNm`, `distanceKm` wrapping geo great-circle.
- [x] 4.2 `find-port` — `findPort`, `findPortStrict`, `findPortsByCountry`, `findPortsByType`, `searchPorts`.
- [x] 4.3 `spatial-index` — `PortSpatialIndex` with 5° bucketing.
- [x] 4.4 `nearest-port` — `findNearestPort`, `findPortsInRadius` using spatial index.
- [x] 4.5 `lane-lookup` — `getShippingLane`, `getShippingLaneStrict`, `getShippingLanesByMode`, `getAllShippingLanes`.
- [x] 4.6 `lane-distance` — `estimateLaneDistance`, `cumulativeDistances`.
- [x] 4.7 `lane-progress` — `findPositionOnLane`.
- [x] 4.8 `eta` — `estimateETA` with `{departureTime?, clock?}` opts.
## 5. Verify
- [x] 5.1 100% coverage on every module (Istanbul, branches/functions/lines/statements).
- [x] 5.2 `bunx turbo lint typecheck test build --filter=@suaveplan/ports` green.
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/ports` green at 100/100/100/100.
- [x] 5.4 Co-located `.md` (≥200 words, 6 sections) for every non-barrel `.ts`.
- [x] 5.5 Root `README.md`.
- [x] 5.6 Refresh `MANIFEST.md`; flip `STATUS.md`.
## 6. Archive
- [x] 6.1 Fold deltas into `openspec/specs/ports/spec.md` (drop `**Verified by:**` lines).
- [x] 6.2 Move folder to `openspec/archive/2026-05-25-enterprise-ports/`.