# 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"`