Files
suaveplan/openspec/archive/2026-05-25-enterprise-ports/specs/ports/spec.md
2026-05-27 23:29:53 +01:00

10 KiB

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"


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"