{
  "openapi": "3.1.0",
  "info": {
    "title": "Port Context Engine API",
    "version": "1.2.0",
    "description": "As-built §11.4 surface of context-api (PRD v2.6). Same-origin behind Caddy in production: the dashboard browser client always calls these paths on its own origin. Error envelope for every non-2xx: {\"error\":{\"code\":\"MACHINE_CODE\",\"message\":\"human text\"}}. Machine codes: VALIDATION, UNAUTHENTICATED, FORBIDDEN, NOT_FOUND, CONFLICT, RATE_LIMITED, QUOTA_EXCEEDED, PLAN_LIMIT, PREVIEW_DISABLED, INTERNAL. Plan limits (D-074): the Free plan includes 1 watchlist and email-only alert rules; Slack/webhook rule channels and webhook subscriptions are Pro. A create beyond the plan is 403 PLAN_LIMIT (GET /api/v1/auth/me -> limits); existing objects are never removed (grandfathered). Password policy: minimum 10 characters (§11.9). Prediction endpoints (forecast, eta-delta) return 503 PREVIEW_DISABLED unless the predictions flag is enabled (§11.0). Multi-port (v1.1): the platform serves the ports enabled in data/ports.json (default SGSIN, NLRTM, USLAX — USLAX is the combined Los Angeles/Long Beach San Pedro Bay complex). Every {locode} (and every port= filter) is validated against the enabled registry: an unknown or disabled locode is 404 NOT_FOUND. port_metrics rows with anchorage \"_port\" are the PORT-LEVEL series (port CI = mean of the anchorage CIs, §11.6.4). v1.2: JWKS (RS256 kid rotation, §11.8), pre-launch waitlist + anonymous funnel counters + ADMIN_TOKEN operator endpoints, per-user alert notification rules (email / Slack / webhook, FE-10/FE-11), and served prediction calls are appended to the §5.4 audit stream (A-PRED-1). Tracing: requests accept a W3C `traceparent` header (NFR-9)."
  },
  "servers": [{ "url": "/", "description": "Same-origin (Caddy reverse-proxies /api -> context-api; §4.4)" }],
  "tags": [
    { "name": "auth" },
    { "name": "vessels" },
    { "name": "ports" },
    { "name": "portcalls" },
    { "name": "alerts" },
    { "name": "watchlists" },
    { "name": "webhooks" },
    { "name": "alert-rules" },
    { "name": "traction", "description": "Pre-launch waitlist + anonymous funnel counters (no cookies, no raw IPs stored)" },
    { "name": "digest", "description": "Weekly congestion digest email: per-user opt-out (Decision D-025)" },
    { "name": "admin", "description": "Operator endpoints behind the static ADMIN_TOKEN bearer; 404 when ADMIN_TOKEN is unset or shorter than 32 characters" },
    { "name": "keys" },
    { "name": "billing" },
    { "name": "config" },
    { "name": "ops" }
  ],
  "paths": {
    "/api/v1/auth/register": {
      "post": {
        "tags": ["auth"],
        "summary": "Create an account (session cookie set)",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RegisterRequest" } } }
        },
        "responses": {
          "201": {
            "description": "Account created and signed in: HttpOnly `pce_access` + `pce_refresh` cookies set; body is the session profile",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Me" } } }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "409": { "$ref": "#/components/responses/ConflictError" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/auth/login": {
      "post": {
        "tags": ["auth"],
        "summary": "Log in (session cookie set)",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/LoginRequest" } } }
        },
        "responses": {
          "200": {
            "description": "HttpOnly `pce_access` + `pce_refresh` cookies set; body is the session profile",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Me" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthenticated" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/auth/refresh": {
      "post": {
        "tags": ["auth"],
        "summary": "Refresh the session cookie",
        "responses": {
          "200": {
            "description": "Fresh session cookie set",
            "content": { "application/json": { "schema": { "type": "object", "properties": { "id": { "type": "integer" } } } } }
          },
          "401": { "$ref": "#/components/responses/Unauthenticated" }
        }
      }
    },
    "/api/v1/auth/logout": {
      "post": {
        "tags": ["auth"],
        "summary": "Revoke the session (cookie cleared)",
        "responses": { "204": { "description": "Logged out" }, "401": { "$ref": "#/components/responses/Unauthenticated" } }
      }
    },
    "/api/v1/auth/me": {
      "get": {
        "tags": ["auth"],
        "summary": "The signed-in account's profile, plan and usage (never cached; no anonymous fallback)",
        "description": "usage.api_calls_today / api_calls_limit / quota_warn report the GAP-12 daily API-key quota (UTC day, all keys combined).",
        "responses": {
          "200": { "description": "Session profile", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Me" } } } },
          "401": { "$ref": "#/components/responses/Unauthenticated" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/auth/password": {
      "post": {
        "tags": ["auth"],
        "summary": "Change the password (revokes every refresh token of the account; a fresh session is set)",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PasswordChangeRequest" } } }
        },
        "responses": {
          "204": { "description": "Password changed; fresh `pce_access` + `pce_refresh` cookies set" },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthenticated" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/auth/account": {
      "delete": {
        "tags": ["auth"],
        "summary": "Delete the account and all its personal data (GDPR Art. 17, Decision D-059); clears the session cookies",
        "description": "Signed-in session only, credential rate bucket (RATE_AUTH_PER_MIN). Re-checks the password and, when two-step verification is on, a TOTP or recovery code (missing code = 400, wrong password or code = 403). Deletes the account and every row keyed by its id or email in one transaction (sessions, API keys and their usage, watchlists, alert rules, webhooks, MFA, lockout counters, digest preference, waitlist entries with the same email), records the erasure (id + time only) for restore replay, audits account.deleted with the numeric id only and sends a best-effort confirmation email.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["password"],
                "properties": {
                  "password": { "type": "string", "description": "Current password" },
                  "totp_code": { "type": "string", "description": "6-digit TOTP or a recovery code; required when MFA is enabled" }
                }
              }
            }
          }
        },
        "responses": {
          "204": { "description": "Account erased; `pce_access` + `pce_refresh` cleared" },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthenticated" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/auth/account/export": {
      "get": {
        "tags": ["auth"],
        "summary": "Download everything held about the account as JSON (GDPR Art. 15/20, Decision D-059)",
        "description": "Signed-in session only, credential rate bucket. `Content-Disposition: attachment`, `Cache-Control: no-store`. Profile, sessions (no token digests), API keys (prefix only), watchlists, alert rules (Slack URLs redacted), webhooks (signing secret redacted), MFA state, digest preference, waitlist entries for the email, usage, and the account's security-log rows. Audited as account.export.",
        "responses": {
          "200": {
            "description": "The export document (format_version 1)",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthenticated" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/vessels/search": {
      "get": {
        "tags": ["vessels"],
        "summary": "Search vessels (name, MMSI, IMO prefix)",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": true,
            "schema": { "type": "string" },
            "description": "Free-text query (name / MMSI / IMO)"
          },
          { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "default": 20 } }
        ],
        "responses": {
          "200": {
            "description": "Vessel summaries",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "vessels": { "type": "array", "items": { "$ref": "#/components/schemas/VesselSummary" } } }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/vessels/{mmsi}": {
      "get": {
        "tags": ["vessels"],
        "summary": "Vessel detail (position, port call, sanctions flag; route, registry, load_state and identity blocks with provenance)",
        "parameters": [{ "name": "mmsi", "in": "path", "required": true, "schema": { "type": "integer" } }],
        "responses": {
          "200": {
            "description": "Vessel detail",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/VesselDetail" } } }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/ports/{locode}/congestion": {
      "get": {
        "tags": ["ports"],
        "summary": "Hourly congestion series from port_metrics (per anchorage + \"_port\" port-level row)",
        "parameters": [
          {
            "name": "locode",
            "in": "path",
            "required": true,
            "schema": { "type": "string", "pattern": "^[A-Z]{2}[A-Z2-9]{3}$" },
            "description": "UN/LOCODE of an enabled registry port (GET /api/v1/ports), e.g. SGSIN, NLRTM, USLAX"
          },
          { "name": "from", "in": "query", "schema": { "type": "string" }, "description": "RFC 3339 or epoch seconds (default now-24h)" },
          { "name": "to", "in": "query", "schema": { "type": "string" }, "description": "RFC 3339 or epoch seconds (default now)" },
          { "name": "granularity", "in": "query", "schema": { "type": "string", "enum": ["1h", "1d"], "default": "1h" } }
        ],
        "responses": {
          "200": {
            "description": "Series (≤ 90 days; clamped=true with an archive pointer beyond)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "port": { "type": "string" },
                    "granularity": { "type": "string" },
                    "from": { "type": "string" },
                    "to": { "type": "string" },
                    "clamped": { "type": "boolean" },
                    "archive": { "type": "string" },
                    "series": { "type": "array", "items": { "$ref": "#/components/schemas/CongestionPoint" } }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/ports/{locode}/queue": {
      "get": {
        "tags": ["ports"],
        "summary": "Queue snapshot: per-anchorage counts, medians, wait histograms (§11.6.4)",
        "parameters": [
          {
            "name": "locode",
            "in": "path",
            "required": true,
            "schema": { "type": "string", "pattern": "^[A-Z]{2}[A-Z2-9]{3}$" },
            "description": "UN/LOCODE of an enabled registry port (GET /api/v1/ports), e.g. SGSIN, NLRTM, USLAX"
          }
        ],
        "responses": {
          "200": {
            "description": "Snapshot",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "port": { "type": "string" },
                    "anchorages": { "type": "array", "items": { "$ref": "#/components/schemas/AnchorageQueue" } },
                    "port_wait_histogram": { "type": "array", "items": { "$ref": "#/components/schemas/WaitBucket" } },
                    "median_wait_min": { "type": ["number", "null"] },
                    "as_of": { "type": "string", "format": "date-time" }
                  }
                }
              }
            }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/ports/{locode}/forecast": {
      "get": {
        "tags": ["ports"],
        "summary": "Congestion forecast (preview)",
        "description": "Flag-gated (predictions.enabled). When enabled, every call that passes the flag and port checks is appended to the §5.4 audit stream as `predictions.forecast` (A-PRED-1).",
        "parameters": [
          {
            "name": "locode",
            "in": "path",
            "required": true,
            "schema": { "type": "string", "pattern": "^[A-Z]{2}[A-Z2-9]{3}$" },
            "description": "UN/LOCODE of an enabled registry port (GET /api/v1/ports), e.g. SGSIN, NLRTM, USLAX"
          },
          {
            "name": "bucket",
            "in": "query",
            "required": true,
            "schema": { "type": "string", "enum": ["passenger", "cargo", "tanker", "other"] }
          },
          {
            "name": "hour",
            "in": "query",
            "required": false,
            "schema": { "type": "integer", "minimum": 0, "maximum": 23 },
            "description": "Arrival hour in the PORT's local timezone (default: now, port-local)"
          }
        ],
        "responses": {
          "200": { "description": "Forecast series (flag-gated)" },
          "503": { "$ref": "#/components/responses/PreviewDisabled" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/api/v1/ports/{locode}/eta-delta": {
      "get": {
        "tags": ["ports"],
        "summary": "Stated-vs-actual ETA delta per ship-type bucket (§11.6.6, preview)",
        "description": "Flag-gated (predictions.enabled). When enabled, every served call is appended to the §5.4 audit stream as `predictions.eta_delta` (A-PRED-1).",
        "parameters": [
          {
            "name": "locode",
            "in": "path",
            "required": true,
            "schema": { "type": "string", "pattern": "^[A-Z]{2}[A-Z2-9]{3}$" },
            "description": "UN/LOCODE of an enabled registry port (GET /api/v1/ports), e.g. SGSIN, NLRTM, USLAX"
          }
        ],
        "responses": {
          "200": {
            "description": "Signed minutes (stated_eta − actual_arrival) over closed calls of the trailing 90 days",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "port": { "type": "string" },
                    "window_days": { "type": "integer" },
                    "definition": { "type": "string" },
                    "excluded_calls": { "type": "integer" },
                    "buckets": { "type": "array", "items": { "$ref": "#/components/schemas/EtaBucket" } }
                  }
                }
              }
            }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "503": { "$ref": "#/components/responses/PreviewDisabled" }
        }
      }
    },
    "/api/v1/flows": {
      "get": {
        "tags": ["ports"],
        "summary": "Declared-destination flows from a port (beta, PRD GAP-21)",
        "description": "Distinct vessels with a port call at the port (arrival in the trailing window), grouped by their latest DECLARED AIS destination, normalized to UN/LOCODE (declared intent, not observed arrival). Destinations naming the port itself are counted in `vessels` but listed in neither `destinations` nor `countries`. China appears as declared-destination geography only. Cached ~5 minutes.",
        "x-inferred": true,
        "parameters": [
          {
            "name": "port",
            "in": "query",
            "required": true,
            "schema": { "type": "string", "pattern": "^[A-Za-z]{2}[A-Za-z2-9]{3}$" },
            "description": "UN/LOCODE of an enabled registry port (GET /api/v1/ports), e.g. SGSIN, NLRTM, USLAX"
          },
          {
            "name": "days",
            "in": "query",
            "required": false,
            "schema": { "type": "integer", "minimum": 1, "maximum": 30, "default": 7 },
            "description": "Trailing window in days (arrival_time)"
          }
        ],
        "responses": {
          "200": {
            "description": "Flows aggregate",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FlowsResponse" } } }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/portcalls": {
      "get": {
        "tags": ["portcalls"],
        "summary": "Port call ledger (paged, filterable)",
        "parameters": [
          { "name": "page", "in": "query", "required": false, "schema": { "type": "integer", "default": 1 } },
          { "name": "mmsi", "in": "query", "required": false, "schema": { "type": "integer" } },
          { "name": "call_type", "in": "query", "required": false, "schema": { "type": "string" } },
          { "name": "order", "in": "query", "required": false, "description": "Arrival-time sort", "schema": { "type": "string", "enum": ["desc", "asc"], "default": "desc" } },
          {
            "name": "port",
            "in": "query",
            "required": false,
            "schema": { "type": "string" },
            "description": "UN/LOCODE filter (enabled registry port; unknown → 404 NOT_FOUND)"
          }
        ],
        "responses": {
          "200": {
            "description": "Port calls page",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "calls": { "type": "array", "items": { "$ref": "#/components/schemas/PortCallRow" } },
                    "page": { "type": "integer" },
                    "pages": { "type": "integer" }
                  }
                }
              }
            }
          },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/api/v1/portcalls/export.csv": {
      "get": {
        "tags": ["portcalls"],
        "summary": "CSV export of the ledger (same filters)",
        "responses": {
          "200": { "description": "CSV file", "content": { "text/csv": { "schema": { "type": "string" } } } },
          "404": { "$ref": "#/components/responses/NotFound" }
        },
        "parameters": [
          {
            "name": "port",
            "in": "query",
            "required": false,
            "schema": { "type": "string" },
            "description": "UN/LOCODE filter (enabled registry port; unknown → 404 NOT_FOUND)"
          }
        ]
      }
    },
    "/api/v1/alerts": {
      "get": {
        "tags": ["alerts"],
        "summary": "Alert feed (acked/unacked)",
        "parameters": [
          { "name": "ack", "in": "query", "schema": { "type": "string", "enum": ["true", "false"] } },
          { "name": "severity", "in": "query", "schema": { "type": "string", "enum": ["critical", "warning", "info"] } },
          { "name": "type", "in": "query", "schema": { "type": "string", "enum": ["sanctions.match", "dwell", "queue"] } },
          {
            "name": "port",
            "in": "query",
            "required": false,
            "schema": { "type": "string" },
            "description": "UN/LOCODE filter (enabled registry port; unknown → 404 NOT_FOUND)"
          }
        ],
        "responses": {
          "200": {
            "description": "Alerts",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "alerts": { "type": "array", "items": { "$ref": "#/components/schemas/AlertRow" } } }
                }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/api/v1/alerts/{id}/ack": {
      "post": {
        "tags": ["alerts"],
        "summary": "Acknowledge an alert (session)",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
        "responses": {
          "204": { "description": "Acked" },
          "401": { "$ref": "#/components/responses/Unauthenticated" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/api/v1/watchlists": {
      "get": {
        "tags": ["watchlists"],
        "summary": "List watchlists (session)",
        "responses": {
          "200": {
            "description": "Watchlists",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "watchlists": { "type": "array", "items": { "$ref": "#/components/schemas/Watchlist" } } }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthenticated" }
        }
      },
      "post": {
        "tags": ["watchlists"],
        "summary": "Create a watchlist (session)",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "type": "object", "required": ["name"], "properties": { "name": { "type": "string", "maxLength": 100 } } }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": { "type": "object", "properties": { "id": { "type": "integer" }, "name": { "type": "string" } } }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthenticated" },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "403": { "$ref": "#/components/responses/PlanLimit" }
        }
      }
    },
    "/api/v1/watchlists/{id}": {
      "delete": {
        "tags": ["watchlists"],
        "summary": "Delete a watchlist (session)",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }],
        "responses": {
          "204": { "description": "Deleted" },
          "401": { "$ref": "#/components/responses/Unauthenticated" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/api/v1/watchlists/{id}/vessels": {
      "get": {
        "tags": ["watchlists"],
        "summary": "Vessels in a watchlist (session)",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }],
        "responses": {
          "200": {
            "description": "Vessel list",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "id": { "type": "integer" }, "mmsi": { "type": "array", "items": { "type": "integer" } }, "vessels": { "type": "array", "description": "The same members as summaries (registry fields from the latest vessels record; MMSI only when none)", "items": { "$ref": "#/components/schemas/VesselSummary" } } }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthenticated" }
        }
      },
      "post": {
        "tags": ["watchlists"],
        "summary": "Add a vessel (session)",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": { "schema": { "type": "object", "required": ["mmsi"], "properties": { "mmsi": { "type": "integer" } } } }
          }
        },
        "responses": {
          "201": {
            "description": "Added",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "id": { "type": "integer" }, "mmsi": { "type": "array", "items": { "type": "integer" } } }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthenticated" },
          "400": { "$ref": "#/components/responses/ValidationError" }
        }
      },
      "delete": {
        "tags": ["watchlists"],
        "summary": "Remove a vessel (session)",
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } },
          { "name": "mmsi", "in": "query", "required": true, "schema": { "type": "integer" } }
        ],
        "responses": {
          "204": { "description": "Removed" },
          "401": { "$ref": "#/components/responses/Unauthenticated" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/api/v1/webhooks": {
      "get": {
        "tags": ["webhooks"],
        "summary": "List webhooks (session)",
        "responses": {
          "200": {
            "description": "Webhooks",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "webhooks": { "type": "array", "items": { "$ref": "#/components/schemas/Webhook" } } }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthenticated" }
        }
      },
      "post": {
        "tags": ["webhooks"],
        "summary": "Create a webhook (session)",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["url", "events"],
                "properties": {
                  "url": { "type": "string", "format": "uri" },
                  "events": {
                    "type": "array",
                    "items": { "type": "string" },
                    "description": "Closed set from GET /api/v1/config -> webhook_events"
                  },
                  "secret": { "type": "string", "maxLength": 256, "description": "Optional HMAC signing key; omitted or blank = generated by the API" }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created. `secret` is the HMAC key of X-PCE-Signature — the one sent, or a generated `whsec_…` key when none was — and is returned only here, never again.",
            "content": { "application/json": { "schema": { "type": "object", "required": ["id", "secret"], "properties": { "id": { "type": "integer" }, "secret": { "type": "string" } } } } }
          },
          "401": { "$ref": "#/components/responses/Unauthenticated" },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "403": { "$ref": "#/components/responses/PlanLimit" }
        }
      }
    },
    "/api/v1/webhooks/{id}": {
      "delete": {
        "tags": ["webhooks"],
        "summary": "Delete a webhook (session)",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }],
        "responses": {
          "204": { "description": "Deleted" },
          "401": { "$ref": "#/components/responses/Unauthenticated" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/api/v1/alert-rules": {
      "get": {
        "tags": ["alert-rules"],
        "summary": "List my alert notification rules (session)",
        "responses": {
          "200": {
            "description": "Rules, ordered by id",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["rules"],
                  "properties": { "rules": { "type": "array", "items": { "$ref": "#/components/schemas/AlertRule" } } }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthenticated" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "post": {
        "tags": ["alert-rules"],
        "summary": "Create an alert notification rule (session; max 20 per account; audited)",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "allOf": [{ "$ref": "#/components/schemas/AlertRuleInput" }, { "required": ["channel", "target"] }] }
            }
          }
        },
        "responses": {
          "201": { "description": "Created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlertRule" } } } },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthenticated" },
          "403": { "$ref": "#/components/responses/PlanLimit" },
          "409": { "$ref": "#/components/responses/ConflictError" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/alert-rules/{id}": {
      "patch": {
        "tags": ["alert-rules"],
        "summary": "Update an alert rule (session; partial; owner-only — a foreign id is 404)",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlertRuleInput" } } }
        },
        "responses": {
          "200": { "description": "Updated rule", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlertRule" } } } },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthenticated" },
          "403": { "$ref": "#/components/responses/PlanLimit" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "delete": {
        "tags": ["alert-rules"],
        "summary": "Delete an alert rule (session; audited)",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }],
        "responses": {
          "204": { "description": "Deleted" },
          "401": { "$ref": "#/components/responses/Unauthenticated" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/alert-rules/{id}/test": {
      "post": {
        "tags": ["alert-rules"],
        "summary": "Send a test notification through the rule's channel (session; audited; 10/min/IP)",
        "description": "Queued asynchronously. Filters and email coalescing are bypassed; the hourly caps (email 10/hour/user, Slack 100/hour/rule, webhook 100/hour/endpoint) still apply. A failure is recorded in the rule's last_error and never disables the rule or the webhook.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }],
        "responses": {
          "202": {
            "description": "Queued",
            "content": {
              "application/json": {
                "schema": { "type": "object", "required": ["queued"], "properties": { "queued": { "type": "boolean", "const": true } } }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthenticated" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/waitlist": {
      "post": {
        "tags": ["traction"],
        "summary": "Join the pre-launch waitlist (anonymous; 5/min/IP; 16 KB body cap)",
        "description": "Valid input ALWAYS answers 202 {\"status\":\"queued\"} — new signup, duplicate (same lowercased email + tier: interests and updated_at are updated) or honeypot hit (non-empty `website`: nothing stored) — so the endpoint never reveals whether an address is enrolled. Semantic validation failures (including consent ≠ true) are 422 VALIDATION; malformed JSON is 400; a body over 16 KB is 413. The raw IP is never stored (salted SHA-256 only); the audit row carries a salted email hash, never the address. Erasure is operator-side (DELETE /api/v1/admin/waitlist or `api -waitlist-erase <email>`).",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WaitlistSignup" } } }
        },
        "responses": {
          "202": {
            "description": "Queued (also for duplicates and honeypot hits)",
            "content": {
              "application/json": {
                "schema": { "type": "object", "required": ["status"], "properties": { "status": { "type": "string", "const": "queued" } } }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "413": { "description": "Body exceeds 16 KB", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "422": { "$ref": "#/components/responses/Unprocessable" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/digest/unsubscribe": {
      "get": {
        "tags": ["digest"],
        "summary": "Weekly-digest opt-out confirmation page (text/html; 10/min/IP)",
        "description": "Target of the per-user link in every digest email (Decision D-025). Renders a confirmation form and never changes state (mail scanners prefetch GET links). The token is a per-user capability that only toggles the digest preference; responses send Referrer-Policy: no-referrer and Cache-Control: no-store.",
        "parameters": [
          {"name": "token", "in": "query", "required": true, "schema": {"type": "string"}}
        ],
        "responses": {
          "200": {"description": "Confirmation form", "content": {"text/html": {"schema": {"type": "string"}}}},
          "404": {"description": "Missing or unknown token", "content": {"text/html": {"schema": {"type": "string"}}}}
        }
      },
      "post": {
        "tags": ["digest"],
        "summary": "Apply the weekly-digest opt-out (RFC 8058 one-click target; 10/min/IP)",
        "description": "Form-encoded. `action=resubscribe` re-subscribes; anything else (including the RFC 8058 body `List-Unsubscribe=One-Click`) unsubscribes. The change is appended to the audit stream.",
        "parameters": [
          {"name": "token", "in": "query", "required": true, "schema": {"type": "string"}}
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "properties": {
                  "action": {"type": "string", "enum": ["unsubscribe", "resubscribe"]},
                  "List-Unsubscribe": {"type": "string", "const": "One-Click"}
                }
              }
            }
          }
        },
        "responses": {
          "200": {"description": "Preference updated", "content": {"text/html": {"schema": {"type": "string"}}}},
          "404": {"description": "Missing or unknown token", "content": {"text/html": {"schema": {"type": "string"}}}}
        }
      }
    },
    "/api/v1/collect": {
      "post": {
        "tags": ["traction"],
        "summary": "Cookieless audience-analytics beacon (120/min/IP; 2 KB body cap; same-origin only)",
        "description": "First-party web analytics (D-065..D-068). Sent by the dashboard with navigator.sendBeacon / fetch keepalive. No cookie is set or required; the IP and User-Agent are only inputs to a daily-salted visitor hash (salt rotated at 00:00 UTC and discarded) and a coarse browser/OS/device parse — neither is stored. Each accepted event is emitted as an OpenTelemetry log record (service.name web-analytics). A valid pce_access cookie marks the event authenticated (plan + a daily HMAC member marker, never the user id). Bots, headless browsers and non-browser clients, unknown event names and malformed paths are dropped silently (still 204). Cross-origin requests (Origin / Sec-Fetch-Site) are refused with 403; no CORS headers are ever sent. FLAG_WEB_ANALYTICS=false turns recording off (still 204).",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CollectBeacon" } } }
        },
        "responses": {
          "204": { "description": "Accepted (recorded or silently dropped)" },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "403": { "description": "Cross-origin beacon", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "413": { "description": "Body exceeds 2 KB", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/events": {
      "post": {
        "tags": ["traction"],
        "summary": "Record one anonymous funnel event (60/min/IP; 16 KB body cap)",
        "description": "Stored only as a daily aggregate counter keyed (date UTC, name, tier, port): no per-visitor row, no IP, no cookie. `path` is validated (≤ 200 chars) but not stored.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FunnelEvent" } } }
        },
        "responses": {
          "204": { "description": "Counted" },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "413": { "description": "Body exceeds 16 KB", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "422": { "$ref": "#/components/responses/Unprocessable" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/admin/waitlist": {
      "get": {
        "tags": ["admin"],
        "summary": "Export the waitlist as CSV (Authorization: Bearer <ADMIN_TOKEN>; audited)",
        "description": "Columns: id,email,tier,interests (';'-joined),company,role,use_case,locale,ref,consent_at,created_at,updated_at (RFC 3339 UTC). Cells starting with = + - @ are prefixed with ' (formula-injection guard). The IP hash is never exported. CLI equivalent: `api -waitlist-export` (reads SQLITE_PATH).",
        "responses": {
          "200": { "description": "CSV attachment", "content": { "text/csv": { "schema": { "type": "string" } } } },
          "401": { "$ref": "#/components/responses/Unauthenticated" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "delete": {
        "tags": ["admin"],
        "summary": "Erase every waitlist row of an address — GDPR erasure (Bearer ADMIN_TOKEN; audited)",
        "description": "CLI equivalent: `api -waitlist-erase <email>`. Answers the number of rows removed (all tiers); 0 when the address is unknown.",
        "parameters": [{ "name": "email", "in": "query", "required": true, "schema": { "type": "string", "maxLength": 254 } }],
        "responses": {
          "200": {
            "description": "Erased",
            "content": {
              "application/json": {
                "schema": { "type": "object", "required": ["deleted"], "properties": { "deleted": { "type": "integer" } } }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthenticated" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "422": { "$ref": "#/components/responses/Unprocessable" }
        }
      }
    },
    "/api/v1/admin/traction": {
      "get": {
        "tags": ["admin"],
        "summary": "Traction report (Bearer ADMIN_TOKEN; audited)",
        "parameters": [
          { "name": "days", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 366, "default": 90 }, "description": "Window for the per-day series (totals are all-time)" }
        ],
        "responses": {
          "200": { "description": "Report", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TractionReport" } } } },
          "401": { "$ref": "#/components/responses/Unauthenticated" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "422": { "$ref": "#/components/responses/Unprocessable" }
        }
      }
    },
    "/api/v1/admin/report": {
      "get": {
        "tags": ["admin"],
        "summary": "Developer report: funnel, activation, API usage, waitlist, port demand, provenance-open rate (Bearer ADMIN_TOKEN; audited)",
        "description": "GAP-15 / GAP-19. Aggregates only. In-memory API usage counters are flushed before the report is computed. scripts/dev-report.sh renders it for a terminal.",
        "parameters": [
          { "name": "days", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 366, "default": 30 }, "description": "Window in UTC days (port_demand and waitlist.total are all-time)" }
        ],
        "responses": {
          "200": { "description": "Report", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DevReport" } } } },
          "401": { "$ref": "#/components/responses/Unauthenticated" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "422": { "$ref": "#/components/responses/Unprocessable" }
        }
      }
    },
    "/.well-known/jwks.json": {
      "get": {
        "tags": ["auth"],
        "summary": "JSON Web Key Set (RFC 7517) for pce_access verification",
        "description": "Every key that currently verifies access tokens: the signing key (JWT_SIGNING_KEY) first, then retired keys still accepted (JWT_PREVIOUS_KEYS) until their tokens expire (§11.8 kid rotation). Cache-Control: public, max-age=300.",
        "responses": {
          "200": {
            "description": "Key set",
            "content": {
              "application/jwk-set+json": {
                "schema": {
                  "type": "object",
                  "required": ["keys"],
                  "properties": { "keys": { "type": "array", "items": { "$ref": "#/components/schemas/JWK" } } }
                }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/auth/jwks.json": {
      "get": {
        "tags": ["auth"],
        "summary": "JSON Web Key Set (alias of /.well-known/jwks.json under the API prefix)",
        "responses": {
          "200": {
            "description": "Key set",
            "content": {
              "application/jwk-set+json": {
                "schema": {
                  "type": "object",
                  "required": ["keys"],
                  "properties": { "keys": { "type": "array", "items": { "$ref": "#/components/schemas/JWK" } } }
                }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/keys": {
      "get": {
        "tags": ["keys"],
        "summary": "List API keys (session; secret never returned again)",
        "description": "Each key carries last_used_at (RFC 3339 | null) and plan (free | pro | team — the account's local plan label, GAP-11).",
        "responses": {
          "200": {
            "description": "Keys",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "keys": { "type": "array", "items": { "$ref": "#/components/schemas/ApiKey" } } }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthenticated" }
        }
      },
      "post": {
        "tags": ["keys"],
        "summary": "Create an API key (session) — plaintext returned ONCE",
        "responses": {
          "201": {
            "description": "Created; the only time the full key is visible",
            "content": {
              "application/json": {
                "schema": { "type": "object", "properties": { "id": { "type": "integer" }, "key": { "type": "string" } } }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthenticated" },
          "403": { "$ref": "#/components/responses/Forbidden" }
        }
      }
    },
    "/api/v1/keys/{id}": {
      "delete": {
        "tags": ["keys"],
        "summary": "Revoke an API key (session)",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }],
        "responses": {
          "204": { "description": "Revoked" },
          "401": { "$ref": "#/components/responses/Unauthenticated" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/api/v1/billing/checkout": {
      "post": {
        "tags": ["billing"],
        "summary": "Create a Stripe Checkout session (session; flag-gated)",
        "responses": {
          "200": {
            "description": "Checkout URL",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "url": { "type": "string", "format": "uri" }, "session_id": { "type": "string" } }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthenticated" },
          "403": { "$ref": "#/components/responses/Forbidden" }
        }
      }
    },
    "/api/v1/billing/webhook": {
      "post": {
        "tags": ["billing"],
        "summary": "Stripe webhook receiver (signature-verified; no session)",
        "responses": {
          "200": {
            "description": "Processed",
            "content": {
              "application/json": {
                "schema": { "type": "object", "properties": { "status": { "type": "string" }, "event_id": { "type": "string" } } }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" }
        }
      }
    },
    "/api/v1/config": {
      "get": {
        "tags": ["config"],
        "summary": "Feature flags, build info, closed webhook event set (public)",
        "responses": {
          "200": {
            "description": "Config",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "flags": { "type": "object", "additionalProperties": { "type": "boolean" } },
                    "build": { "type": "object", "properties": { "commit": { "type": "string" }, "build_id": { "type": "string" } } },
                    "webhook_events": { "type": "array", "items": { "type": "string" } }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/healthz": { "get": { "tags": ["ops"], "summary": "Liveness", "responses": { "200": { "description": "Alive" } } } },
    "/readyz": {
      "get": {
        "tags": ["ops"],
        "summary": "Readiness (dependencies checked)",
        "responses": { "200": { "description": "Ready" }, "503": { "description": "Not ready" } }
      }
    },
    "/ws/vessels": {
      "get": {
        "tags": ["ops"],
        "summary": "WebSocket stream (snapshot + batch + heartbeat frames, §11.3)",
        "description": "Upgrade to WebSocket. Frame tags (protobuf): 0x0a snapshot, 0x12 position batch, 0x1a heartbeat. Heartbeats carry the server ms timestamp; clients measure in-cycle spread.",
        "parameters": [
          { "name": "mmsi", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Optional MMSI filter list" }
        ],
        "responses": { "101": { "description": "Switching Protocols" } }
      }
    },
    "/api/v1/coverage": {
      "get": {
        "tags": ["ports"],
        "summary": "Coverage manifest (GAP-9): sources, blind spots, per-port stage, fences, calibration, empirical coverage and PortWatch validation",
        "description": "Public; cached for 5 minutes.",
        "responses": {
          "200": {
            "description": "The manifest",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CoverageManifest" } } }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/ports": {
      "get": {
        "tags": ["ports"],
        "summary": "Enabled ports (registry, data/ports.json)",
        "responses": {
          "200": {
            "description": "Ports in PORTS order",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["ports"],
                  "properties": { "ports": { "type": "array", "items": { "$ref": "#/components/schemas/Port" } } }
                }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/ports/{locode}/summary": {
      "get": {
        "tags": ["ports"],
        "summary": "Port tile: live counts, CI, median wait, alerts, weather",
        "parameters": [
          {
            "name": "locode",
            "in": "path",
            "required": true,
            "schema": { "type": "string", "pattern": "^[A-Z]{2}[A-Z2-9]{3}$" },
            "description": "UN/LOCODE of an enabled registry port (GET /api/v1/ports), e.g. SGSIN, NLRTM, USLAX"
          }
        ],
        "responses": {
          "200": {
            "description": "Port summary",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PortSummary" } } }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/ports/{locode}/weather": {
      "get": {
        "tags": ["ports"],
        "summary": "Latest weather observation at the port centroid (§11.6.9)",
        "parameters": [
          {
            "name": "locode",
            "in": "path",
            "required": true,
            "schema": { "type": "string", "pattern": "^[A-Z]{2}[A-Z2-9]{3}$" },
            "description": "UN/LOCODE of an enabled registry port (GET /api/v1/ports), e.g. SGSIN, NLRTM, USLAX"
          }
        ],
        "responses": {
          "200": {
            "description": "Latest observation with its age (FE-8)",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    { "$ref": "#/components/schemas/Weather" },
                    {
                      "type": "object",
                      "required": ["locode", "age_seconds"],
                      "properties": { "locode": { "type": "string" }, "age_seconds": { "type": "integer" } }
                    }
                  ]
                }
              }
            }
          },
          "404": { "description": "Unknown port, or no observation yet (NOT_FOUND envelope)" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/vessels/{mmsi}/track": {
      "get": {
        "tags": ["vessels"],
        "summary": "Vessel track (FE-2), ≤ 1 point per minute",
        "parameters": [
          { "name": "mmsi", "in": "path", "required": true, "schema": { "type": "integer" } },
          { "name": "hours", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 72, "default": 24 } }
        ],
        "responses": {
          "200": {
            "description": "Track from ais_positions (≤ 7 d), falling back to ais_positions_1m",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["mmsi", "points"],
                  "properties": {
                    "mmsi": { "type": "integer" },
                    "hours": { "type": "integer" },
                    "source": { "type": "string", "enum": ["raw", "1m"] },
                    "points": { "type": "array", "items": { "$ref": "#/components/schemas/TrackPoint" } }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "apiKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "`Authorization: Bearer pk_…` (POST /api/v1/keys). GAP-12 metering: every /api/ call with a VALID key counts against the owning account's daily quota (UTC day, all keys combined; plan limits free 1,000 · pro 20,000 · team 200,000 calls/day by default, QUOTA_FREE_PER_DAY / QUOTA_PRO_PER_DAY / QUOTA_TEAM_PER_DAY). Every metered response carries X-RateLimit-Limit-Day, X-RateLimit-Remaining-Day and X-RateLimit-Reset-Day, plus X-RateLimit-Warning once ≥ 80 % is used; at 100 % the call is refused with 429 QUOTA_EXCEEDED (see components.responses.QuotaExceeded). Session-cookie and anonymous traffic is not metered (per-minute per-IP buckets still apply). No billing is attached (billing.enabled=false)."
      }
    },
    "headers": {
      "X-RateLimit-Limit-Day": { "schema": { "type": "integer" }, "description": "GAP-12: the account's daily API-key call limit (its plan)" },
      "X-RateLimit-Remaining-Day": { "schema": { "type": "integer" }, "description": "GAP-12: calls left today (UTC) after this one" },
      "X-RateLimit-Reset-Day": { "schema": { "type": "integer" }, "description": "GAP-12: unix seconds of the next 00:00 UTC reset" },
      "X-RateLimit-Warning": { "schema": { "type": "string" }, "description": "GAP-12: present once ≥ 80 % of the daily quota is used, e.g. \"80% of the daily API quota used\"" }
    },
    "responses": {
      "ValidationError": {
        "description": "VALIDATION — malformed input",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "error": { "code": "VALIDATION", "message": "password must be at least 10 characters" } }
          }
        }
      },
      "Unauthenticated": {
        "description": "UNAUTHENTICATED",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "Forbidden": {
        "description": "FORBIDDEN — flag-gated surface disabled",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "NotFound": { "description": "NOT_FOUND", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
      "ConflictError": {
        "description": "CONFLICT — e.g. email already registered",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "RateLimited": {
        "description": "RATE_LIMITED",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "QuotaExceeded": {
        "description": "QUOTA_EXCEEDED (429) — GAP-12: the account's DAILY API-key quota is used up (refused calls are not counted). Distinct from the per-minute RATE_LIMITED buckets: Retry-After is the number of seconds to the next 00:00 UTC reset.",
        "headers": {
          "Retry-After": { "schema": { "type": "integer" }, "description": "Seconds until the quota resets (00:00 UTC)" },
          "X-RateLimit-Limit-Day": { "$ref": "#/components/headers/X-RateLimit-Limit-Day" },
          "X-RateLimit-Remaining-Day": { "$ref": "#/components/headers/X-RateLimit-Remaining-Day" },
          "X-RateLimit-Reset-Day": { "$ref": "#/components/headers/X-RateLimit-Reset-Day" }
        },
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "error": { "code": "QUOTA_EXCEEDED", "message": "daily API quota exhausted (1000 calls per UTC day on this plan)" } }
          }
        }
      },
      "Unprocessable": {
        "description": "VALIDATION (422) — semantically invalid field on the traction/admin endpoints",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "PlanLimit": {
        "description": "PLAN_LIMIT (403) — D-074: the account's plan does not include this create. Free: 1 watchlist, email-only alert rules (moving a rule to Slack/webhook is refused the same way), no webhook subscriptions. Pro/Team have no plan cap beyond the abuse bounds. Existing objects beyond the plan are kept (grandfathered); reads and deletes always work.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "examples": {
              "watchlists": { "value": { "error": { "code": "PLAN_LIMIT", "message": "the Free plan includes 1 watchlist" } } },
              "channel": { "value": { "error": { "code": "PLAN_LIMIT", "message": "Slack and webhook alerts are part of Pro" } } },
              "webhooks": { "value": { "error": { "code": "PLAN_LIMIT", "message": "webhooks are part of Pro" } } }
            }
          }
        }
      },
      "PreviewDisabled": {
        "description": "PREVIEW_DISABLED — predictions flag off (§11.0)",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "error": { "code": "PREVIEW_DISABLED", "message": "predictions are a preview and currently disabled" } }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": ["error"],
        "properties": {
          "error": {
            "type": "object",
            "required": ["code", "message"],
            "properties": {
              "code": {
                "type": "string",
                "enum": [
                  "VALIDATION",
                  "UNAUTHENTICATED",
                  "FORBIDDEN",
                  "NOT_FOUND",
                  "CONFLICT",
                  "RATE_LIMITED",
                  "PREVIEW_DISABLED",
                  "INTERNAL",
                  "QUOTA_EXCEEDED",
                  "PLAN_LIMIT"
                ]
              },
              "message": { "type": "string" }
            }
          }
        }
      },
      "RegisterRequest": {
        "type": "object",
        "required": ["email", "password"],
        "properties": { "email": { "type": "string", "format": "email" }, "password": { "type": "string", "minLength": 10 } }
      },
      "LoginRequest": {
        "type": "object",
        "required": ["email", "password"],
        "properties": { "email": { "type": "string", "format": "email" }, "password": { "type": "string" } }
      },
      "PasswordChangeRequest": {
        "type": "object",
        "required": ["current_password", "new_password"],
        "properties": { "current_password": { "type": "string" }, "new_password": { "type": "string", "minLength": 10 } }
      },
      "Me": {
        "type": "object",
        "required": ["id", "email", "plan", "created_at", "usage"],
        "properties": {
          "id": { "type": "integer" },
          "email": { "type": "string" },
          "plan": { "type": "string", "enum": ["free", "pro", "api"] },
          "created_at": { "type": "integer", "description": "Unix seconds" },
          "usage": {
            "type": "object",
            "required": ["api_keys", "watchlists", "alert_rules", "webhooks"],
            "properties": {
              "api_keys": { "type": "integer" },
              "watchlists": { "type": "integer" },
              "alert_rules": { "type": "integer" },
              "webhooks": { "type": "integer" },
              "api_calls_today": { "type": "integer", "description": "GAP-12: API-key calls counted today (UTC day), all of the account's keys combined" },
              "api_calls_limit": { "type": "integer", "description": "GAP-12: the plan's daily API-key call limit (free 1,000 · pro 20,000 · team 200,000 by default; QUOTA_*_PER_DAY)" },
              "quota_warn": { "type": "boolean", "description": "GAP-12: true once api_calls_today ≥ 80 % of api_calls_limit" }
            }
          },
          "limits": {
            "type": "object",
            "description": "D-074: what the account's plan includes (creates beyond it are 403 PLAN_LIMIT). Free: {watchlists: 1, channels: [\"email\"], webhooks: false}; Pro/Team: {watchlists: 0, channels: [\"email\", \"slack\", \"webhook\"], webhooks: true}.",
            "required": ["watchlists", "channels", "webhooks"],
            "properties": {
              "watchlists": { "type": "integer", "description": "The plan's watchlist cap; 0 = no plan cap" },
              "channels": { "type": "array", "items": { "type": "string", "enum": ["email", "slack", "webhook"] }, "description": "Alert-rule channels the plan includes" },
              "webhooks": { "type": "boolean", "description": "Whether webhook subscriptions are included" }
            }
          }
        }
      },
      "VesselSummary": {
        "type": "object",
        "required": ["mmsi", "ship_type_bucket", "last_updated_ms"],
        "properties": {
          "mmsi": { "type": "integer" },
          "name": { "type": "string" },
          "imo": { "type": "string" },
          "ship_type": { "type": "integer" },
          "ship_type_bucket": { "type": "string" },
          "last_updated_ms": { "type": "integer" }
        }
      },
      "VesselDetail": {
        "type": "object",
        "required": ["mmsi", "port", "sanctions_flag", "ship_type_bucket", "voyage"],
        "properties": {
          "mmsi": { "type": "integer" },
          "imo": { "type": "string" },
          "name": { "type": "string" },
          "callsign": { "type": "string" },
          "flag": { "type": "string" },
          "ship_type": { "type": "integer" },
          "ship_type_bucket": { "type": "string" },
          "gross_tonnage": { "type": "number" },
          "dwt": { "type": "number" },
          "last_updated_ms": { "type": "integer" },
          "position": {
            "type": ["object", "null"],
            "properties": {
              "time_ms": { "type": "integer" },
              "latitude": { "type": "number" },
              "longitude": { "type": "number" },
              "sog_kn": { "type": "number" },
              "cog_deg": { "type": "number" },
              "heading_deg": { "type": "number" },
              "nav_status": { "type": "integer" }
            }
          },
          "sanctions_flag": { "type": "boolean" },
          "active_port_call": {
            "type": ["object", "null"],
            "properties": {
              "id": { "type": "integer" },
              "port_loccode": { "type": "string" },
              "call_type": { "type": "string" },
              "arrival_ms": { "type": "integer" }
            }
          },
          "voyage": {
            "type": "object",
            "required": ["destination", "eta", "draught_m", "port"],
            "description": "AIS voyage data (ShipStaticData Destination/ETA/Draught, §11.5)",
            "properties": {
              "destination": { "type": ["string", "null"] },
              "eta": { "type": ["string", "null"], "format": "date-time" },
              "draught_m": { "type": ["number", "null"] },
              "port": { "type": ["string", "null"], "description": "Same as the top-level port" }
            }
          },
          "port": { "type": ["string", "null"], "description": "Enabled port whose bbox contains the latest position (null if none)" },
          "route": { "$ref": "#/components/schemas/VesselRoute" },
          "registry": { "$ref": "#/components/schemas/VesselRegistry" },
          "load_state": { "$ref": "#/components/schemas/LoadState" },
          "identity": { "$ref": "#/components/schemas/VesselIdentity" }
        }
      },
      "VesselRoute": {
        "type": "object",
        "description": "PRD GAP-6 read-time voyage: where from (last observed closed port call), where to (normalized declared destination), when (declared ETA). Additive to the certified `voyage` block.",
        "x-inferred": true,
        "required": ["origin", "destination", "eta"],
        "properties": {
          "origin": {
            "x-inferred": true,
            "description": "methods: last_port_call (port of the last CLOSED port call; sources [port_calls]) | entered_coverage (honest null: no closed call observed)",
            "allOf": [
              { "$ref": "#/components/schemas/Provenance" },
              {
                "properties": {
                  "method": { "enum": ["last_port_call", "entered_coverage"] },
                  "value": {
                    "type": ["object", "null"],
                    "required": ["locode", "name", "country", "left_at"],
                    "properties": {
                      "locode": { "type": "string" },
                      "name": { "type": ["string", "null"] },
                      "country": { "type": "string", "description": "ISO 3166-1 alpha-2" },
                      "left_at": { "type": ["string", "null"], "format": "date-time" }
                    }
                  }
                }
              }
            ]
          },
          "destination": {
            "x-inferred": true,
            "description": "Declared AIS destination normalized to UN/LOCODE (PRD GAP-5). methods (tiers): locode_exact | alias | name_match | country_only (value has country only) | unresolved (value null) | not_broadcast (value null)",
            "allOf": [
              { "$ref": "#/components/schemas/Provenance" },
              {
                "properties": {
                  "method": { "enum": ["locode_exact", "alias", "name_match", "country_only", "unresolved", "not_broadcast"] },
                  "value": {
                    "type": ["object", "null"],
                    "required": ["locode", "name", "country", "raw"],
                    "properties": {
                      "locode": { "type": ["string", "null"] },
                      "name": { "type": ["string", "null"], "description": "UN/LOCODE name" },
                      "country": { "type": ["string", "null"], "description": "ISO 3166-1 alpha-2" },
                      "raw": { "type": "string", "description": "Broadcast text, '@' padding removed" }
                    }
                  }
                }
              }
            ]
          },
          "eta": {
            "x-inferred": true,
            "description": "methods: ais_declared (crew-entered; low confidence when in the past) | not_broadcast",
            "allOf": [
              { "$ref": "#/components/schemas/Provenance" },
              {
                "properties": {
                  "method": { "enum": ["ais_declared", "not_broadcast"] },
                  "value": { "type": ["string", "null"], "format": "date-time" }
                }
              }
            ]
          }
        }
      },
      "VesselRegistry": {
        "type": "object",
        "description": "PRD GAP-4 vessel registry from AIS + open data only. DWT / gross tonnage have no open source (Equasis terms forbid automated use): honest nulls. AIS dimensions are not persisted yet: length/beam are honest nulls.",
        "x-inferred": true,
        "required": ["type", "flag", "length_m", "beam_m", "max_draught_m", "dwt", "gross_tonnage"],
        "properties": {
          "type": {
            "x-inferred": true,
            "description": "ITU-R M.1371 Table 53 (data/ship_types.json). methods: ais_static_type | not_broadcast",
            "allOf": [
              { "$ref": "#/components/schemas/Provenance" },
              {
                "properties": {
                  "method": { "enum": ["ais_static_type", "not_broadcast"] },
                  "value": {
                    "type": ["object", "null"],
                    "required": ["code", "category", "label"],
                    "properties": {
                      "code": { "type": "integer", "minimum": 1, "maximum": 99 },
                      "category": { "type": "string", "enum": ["cargo", "tanker", "passenger", "other"] },
                      "label": { "type": "string" }
                    }
                  }
                }
              }
            ]
          },
          "flag": {
            "x-inferred": true,
            "description": "Flag implied by the MMSI's ITU MID (the administration that assigned the MMSI, not a registry record). methods: mmsi_mid | unresolved",
            "allOf": [
              { "$ref": "#/components/schemas/Provenance" },
              {
                "properties": {
                  "method": { "enum": ["mmsi_mid", "unresolved"] },
                  "value": {
                    "type": ["object", "null"],
                    "required": ["country", "mid"],
                    "properties": {
                      "country": { "type": "string", "description": "ISO 3166-1 alpha-2" },
                      "mid": { "type": "integer" }
                    }
                  }
                }
              }
            ]
          },
          "length_m": { "x-inferred": true, "description": "methods: ais_dimensions | not_broadcast (currently always: dimensions are not persisted)", "allOf": [{ "$ref": "#/components/schemas/Provenance" }, { "properties": { "method": { "enum": ["ais_dimensions", "not_broadcast"] }, "value": { "type": ["number", "null"] } } }] },
          "beam_m": { "x-inferred": true, "description": "methods: ais_dimensions | not_broadcast (currently always: dimensions are not persisted)", "allOf": [{ "$ref": "#/components/schemas/Provenance" }, { "properties": { "method": { "enum": ["ais_dimensions", "not_broadcast"] }, "value": { "type": ["number", "null"] } } }] },
          "max_draught_m": { "x-inferred": true, "description": "Maximum AIS draught broadcast in the trailing 90 days. methods: max_observed_90d | no_basis", "allOf": [{ "$ref": "#/components/schemas/Provenance" }, { "properties": { "method": { "enum": ["max_observed_90d", "no_basis"] }, "value": { "type": ["number", "null"] } } }] },
          "dwt": { "x-inferred": true, "description": "methods: not_open_data | registry_record", "allOf": [{ "$ref": "#/components/schemas/Provenance" }, { "properties": { "method": { "enum": ["not_open_data", "registry_record"] }, "value": { "type": ["number", "null"] } } }] },
          "gross_tonnage": { "x-inferred": true, "description": "methods: not_open_data | registry_record", "allOf": [{ "$ref": "#/components/schemas/Provenance" }, { "properties": { "method": { "enum": ["not_open_data", "registry_record"] }, "value": { "type": ["number", "null"] } } }] }
        }
      },
      "LoadState": {
        "x-inferred": true,
        "description": "PRD GAP-7 load event (re-scoped): never a fact — AIS draught is crew-entered. methods: draught_change_at_call (draught change across the last port call; confidence ≤ 0.6) | draught_ratio_observed (current / max observed; confidence ≤ 0.5; needs ≥ 2 distinct draughts) | no_basis (band unknown, confidence 0)",
        "allOf": [
          { "$ref": "#/components/schemas/Provenance" },
          {
            "properties": {
              "method": { "enum": ["draught_change_at_call", "draught_ratio_observed", "no_basis"] },
              "value": {
                "type": "object",
                "required": ["band", "draught_m", "max_draught_m", "load_pct", "last_change"],
                "properties": {
                  "band": { "type": "string", "enum": ["laden", "partial", "ballast", "unknown"] },
                  "draught_m": { "type": ["number", "null"] },
                  "max_draught_m": { "type": ["number", "null"] },
                  "load_pct": { "type": ["number", "null"], "description": "current / max observed draught × 100" },
                  "last_change": {
                    "type": ["object", "null"],
                    "required": ["delta_m", "at", "port"],
                    "properties": {
                      "delta_m": { "type": "number", "description": "Draught after the call minus before (m)" },
                      "at": { "type": "string", "format": "date-time" },
                      "port": { "type": ["string", "null"], "description": "UN/LOCODE of the call" }
                    }
                  }
                }
              }
            }
          }
        ]
      },
      "VesselIdentity": {
        "type": "object",
        "x-inferred": true,
        "description": "PRD GAP-20 identity checks. reasons ids: imo_checksum (IMO check digit fails) | mid_unassigned (MMSI MID not allocated by ITU) | flag_mid_mismatch (a declared flag differs from the MID's country; AIS carries no flag, so only when a registry flag exists)",
        "required": ["imo_valid", "mid_country", "suspect", "reasons"],
        "properties": {
          "imo_valid": { "type": ["boolean", "null"], "description": "null: no IMO broadcast" },
          "mid_country": { "type": ["string", "null"], "description": "ISO 3166-1 alpha-2 of the MMSI's MID" },
          "suspect": { "type": "boolean" },
          "reasons": { "type": "array", "items": { "type": "string", "enum": ["imo_checksum", "mid_unassigned", "flag_mid_mismatch"] } }
        }
      },
      "FlowsResponse": {
        "type": "object",
        "x-inferred": true,
        "required": ["port", "window_days", "beta", "as_of", "vessels", "unresolved", "destinations", "countries"],
        "properties": {
          "port": { "type": "string" },
          "window_days": { "type": "integer" },
          "beta": { "type": "boolean", "const": true },
          "as_of": { "type": "string", "format": "date-time" },
          "vessels": { "type": "integer", "description": "Distinct MMSIs with a port call at the port in the window" },
          "unresolved": { "type": "integer", "description": "Vessels whose declared destination is absent or unresolvable" },
          "destinations": {
            "type": "array",
            "description": "Top 25 by vessels, descending; the port itself excluded",
            "items": {
              "type": "object",
              "required": ["locode", "name", "country", "vessels", "confidence"],
              "properties": {
                "locode": { "type": ["string", "null"], "description": "null: country-only resolution" },
                "name": { "type": ["string", "null"] },
                "country": { "type": ["string", "null"] },
                "vessels": { "type": "integer" },
                "confidence": { "type": "number", "minimum": 0, "maximum": 1, "description": "Mean normalizer confidence of the grouped vessels", "x-inferred": true }
              }
            }
          },
          "countries": {
            "type": "array",
            "items": {
              "type": "object",
              "required": ["country", "vessels"],
              "properties": { "country": { "type": "string" }, "vessels": { "type": "integer" } }
            }
          }
        }
      },
      "CongestionPoint": {
        "type": "object",
        "properties": {
          "time_ms": { "type": "integer" },
          "anchorage": { "type": "string", "description": "Fence name, or \"_port\" for the port-level row" },
          "vessels_at_anchor": { "type": "integer" },
          "wait_sum_minutes": { "type": "number" },
          "congestion_index": { "type": "number" },
          "median_wait_min": { "type": ["number", "null"] }
        }
      },
      "AnchorageQueue": {
        "type": "object",
        "properties": {
          "anchorage": { "type": "string" },
          "vessels_at_anchor": { "type": "integer" },
          "median_wait_min": { "type": ["number", "null"], "description": "null: no departures in the trailing 24 h" },
          "as_of_ms": { "type": "integer" },
          "age_seconds": { "type": "integer" },
          "wait_histogram": { "type": "array", "items": { "$ref": "#/components/schemas/WaitBucket" } }
        }
      },
      "Provenance": {
        "type": "object",
        "description": "PRD GAP-9 provenance contract: every inferred value carries its confidence (0-1), a stable method id, source ids and freshness. value null with a method = an honest null.",
        "required": ["value", "confidence", "method", "sources", "as_of"],
        "properties": {
          "value": {},
          "confidence": { "type": "number", "minimum": 0, "maximum": 1 },
          "method": { "type": "string" },
          "sources": { "type": "array", "items": { "type": "string" } },
          "as_of": { "type": ["string", "null"], "format": "date-time" }
        }
      },
      "ProvenanceMeta": {
        "type": "object",
        "description": "Provenance without a value (the value is served elsewhere in the same response) — internal/provenance.Meta.",
        "required": ["confidence", "method", "sources", "as_of"],
        "properties": {
          "confidence": { "type": "number", "minimum": 0, "maximum": 1 },
          "method": { "type": "string" },
          "sources": { "type": "array", "items": { "type": "string" } },
          "as_of": { "type": ["string", "null"], "format": "date-time" }
        }
      },
      "PortStage": {
        "type": "string",
        "enum": ["live", "pilot", "calibrating"],
        "description": "live: fences, calls, congestion; pilot: same, labelled pilot; calibrating: AIS positions and coverage only (no calls, no congestion)."
      },
      "CoverageManifest": {
        "type": "object",
        "required": ["as_of", "sources", "blind_spots", "ports"],
        "properties": {
          "as_of": { "type": "string", "format": "date-time" },
          "sources": {
            "type": "array",
            "items": {
              "type": "object",
              "required": ["id", "role", "licence", "single_source"],
              "properties": {
                "id": { "type": "string", "enum": ["aisstream", "osm", "portwatch", "unlocode", "itu_mid", "open_meteo", "ofac"] },
                "role": { "type": "string", "enum": ["positions", "fences", "validation", "locodes", "flags", "weather", "sanctions"] },
                "licence": { "type": "string" },
                "single_source": { "type": "boolean", "description": "True when the role has no second source (AIS positions)" }
              }
            }
          },
          "blind_spots": {
            "type": "array",
            "items": {
              "type": "object",
              "required": ["id", "severity"],
              "properties": {
                "id": { "type": "string", "enum": ["single_source", "china_excluded", "origin_gap", "terrestrial_coverage", "draught_quality", "portwatch_lag"] },
                "severity": { "type": "string", "enum": ["high", "medium", "low"] }
              }
            }
          },
          "ports": { "type": "array", "items": { "$ref": "#/components/schemas/CoveragePort" } }
        }
      },
      "CoveragePort": {
        "type": "object",
        "required": ["locode", "name", "country", "stage", "anchorages", "zones", "fence_sources", "calibration_start", "history_hours", "coverage", "validation"],
        "properties": {
          "locode": { "type": "string" },
          "name": { "type": "string" },
          "country": { "type": "string" },
          "stage": { "$ref": "#/components/schemas/PortStage" },
          "anchorages": { "type": "integer" },
          "zones": { "type": "integer" },
          "fence_sources": { "type": "array", "items": { "type": "string" }, "description": "Source ids of the port's fences (osm, rijkswaterstaat, us_33_cfr_110, mpa_singapore, other)" },
          "calibration_start": { "type": ["string", "null"], "format": "date-time", "description": "First port_metrics row; null if none" },
          "history_hours": { "type": "integer", "description": "Hours since calibration_start (0 if none)" },
          "coverage": {
            "type": "object",
            "description": "PRE-2 empirical coverage: AIS positions inside the port bbox over the last 24 h; null when the position feed itself had no data",
            "required": ["reports_per_hour", "vessels_24h", "as_of"],
            "properties": {
              "reports_per_hour": { "type": ["number", "null"] },
              "vessels_24h": { "type": ["integer", "null"] },
              "as_of": { "type": "string", "format": "date-time" }
            }
          },
          "validation": {
            "type": "object",
            "description": "GAP-16 agreement with IMF PortWatch daily port calls over the days both series exist in the trailing window (ours = distinct MMSIs arriving that UTC day). Only the score is published; attribution: IMF PortWatch.",
            "required": ["reference", "window_days", "days_compared", "ours_per_day", "reference_per_day", "ratio", "correlation", "as_of"],
            "properties": {
              "reference": { "type": "string", "enum": ["portwatch"] },
              "window_days": { "type": "integer" },
              "days_compared": { "type": "integer" },
              "ours_per_day": { "type": ["number", "null"] },
              "reference_per_day": { "type": ["number", "null"] },
              "ratio": { "type": ["number", "null"], "description": "Σours / Σreference" },
              "correlation": { "type": ["number", "null"], "description": "Pearson r of the daily counts; null below 7 days or for a constant series" },
              "as_of": { "type": ["string", "null"], "format": "date", "description": "Last compared day (YYYY-MM-DD)" }
            }
          }
        }
      },
      "PortCallRow": {
        "type": "object",
        "properties": {
          "id": { "type": "integer" },
          "mmsi": { "type": "integer" },
          "port_loccode": { "type": "string" },
          "arrival_ms": { "type": "integer" },
          "departure_ms": { "type": ["integer", "null"] },
          "anchorage_minutes": { "type": "integer" },
          "berth_minutes": { "type": "integer" },
          "total_minutes": { "type": "integer" },
          "call_type": { "type": "string" },
          "co2_tonnes": { "type": ["number", "null"] },
          "fence": { "type": ["string", "null"], "description": "Anchorage or berth area of the phase; null when the berth was inferred from a stationary episode (no published berth polygon)." },
          "ongoing": { "type": "boolean", "description": "True while the phase is open; the minutes are then the time elapsed so far." }
        }
      },
      "AlertRow": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "time_ms": { "type": "integer" },
          "mmsi": { "type": "integer" },
          "type": { "type": "string" },
          "severity": { "type": "string", "enum": ["critical", "warning", "info"] },
          "port_loccode": { "type": "string" },
          "anchorage": { "type": "string" },
          "message": { "type": "string" },
          "source": { "type": "string" },
          "acked": { "type": "boolean" }
        }
      },
      "Watchlist": {
        "type": "object",
        "properties": {
          "id": { "type": "integer" },
          "name": { "type": "string" },
          "created_at": { "type": "integer" },
          "vessel_count": { "type": "integer" }
        }
      },
      "Webhook": {
        "type": "object",
        "properties": {
          "id": { "type": "integer" },
          "url": { "type": "string" },
          "events": { "type": "array", "items": { "type": "string" } },
          "created_at": { "type": "integer" },
          "disabled": { "type": "boolean" }
        }
      },
      "AlertRuleType": {
        "type": "string",
        "enum": ["sanctions.match", "dwell", "queue", "portcall.arrival", "portcall.departure", "wait.p80"],
        "description": "Routable event types: the alerts.new types plus port-call transitions (port-call events have severity info). wait.p80 (GAP-23, severity warning) fires when a watched vessel's OPEN anchorage phase has lasted longer than its port's P80 anchorage wait over the trailing 30 days of departed anchorage phases; fewer than 30 samples → P80 null → no alert. It is evaluated every 5 minutes, fires once per (rule, vessel, phase), only for rules that list it explicitly ([] = all types does NOT include it) and requires watchlist_id or mmsi. Payload: {type, rule_id, mmsi, port_loccode, anchorage, arrival_ms, elapsed_minutes, p80_minutes, sample_count, window_days, time}."
      },
      "AlertRule": {
        "type": "object",
        "description": "Per-user notification rule (FE-10/FE-11, §11.8). Filters AND together: port (\"\" = all), types ([] = all), severity ≥ min_severity, and — when watchlist_id is set — the event's vessel must be on that watchlist (port-level events such as queue alerts never match a watchlist-scoped rule). Deleting the watchlist disables the rule (last_error \"watchlist deleted\").",
        "required": ["id", "channel", "target", "port", "types", "min_severity", "watchlist_id", "enabled", "created_at", "last_error"],
        "properties": {
          "id": { "type": "integer" },
          "channel": { "type": "string", "enum": ["email", "slack", "webhook"] },
          "target": {
            "type": "string",
            "maxLength": 512,
            "description": "email: the account's own email address (no other address is accepted); slack: an https://hooks.slack.com/ incoming-webhook URL; webhook: the decimal id of one of the caller's active webhook subscriptions (delivery uses its URL and HMAC secret)"
          },
          "port": { "type": "string", "description": "Enabled UN/LOCODE, or \"\" for all ports", "examples": ["SGSIN", ""] },
          "types": { "type": "array", "items": { "$ref": "#/components/schemas/AlertRuleType" }, "description": "[] = all types" },
          "min_severity": { "type": "string", "enum": ["info", "warning", "critical"] },
          "watchlist_id": { "type": ["integer", "null"] },
          "mmsi": { "type": ["integer", "null"], "description": "GAP-23 single-vessel scope (9-digit MMSI): only that vessel's events match; null = any vessel" },
          "enabled": { "type": "boolean" },
          "created_at": { "type": "string", "format": "date-time", "description": "RFC 3339, UTC" },
          "last_error": {
            "type": ["string", "null"],
            "description": "Last delivery failure (cleared by the next success or by re-enabling). A Slack rule whose retries (0 / +1 min / +10 min) are exhausted is auto-disabled with the reason here and the owner is emailed."
          }
        }
      },
      "AlertRuleInput": {
        "type": "object",
        "description": "AlertRule minus id / created_at / last_error. POST requires channel and target (defaults: port \"\", types [], min_severity info, watchlist_id null, enabled true). PATCH accepts any subset; watchlist_id null clears the watchlist filter; the merged rule is re-validated.",
        "properties": {
          "channel": { "type": "string", "enum": ["email", "slack", "webhook"] },
          "target": { "type": "string", "maxLength": 512 },
          "port": { "type": "string" },
          "types": { "type": "array", "items": { "$ref": "#/components/schemas/AlertRuleType" } },
          "min_severity": { "type": "string", "enum": ["info", "warning", "critical"] },
          "watchlist_id": { "type": ["integer", "null"] },
          "mmsi": { "type": ["integer", "null"], "minimum": 100000000, "maximum": 999999999, "description": "GAP-23 single-vessel scope; null clears it. wait.p80 rules need watchlist_id or mmsi (400 VALIDATION otherwise)" },
          "enabled": { "type": "boolean" }
        }
      },
      "WaitlistSignup": {
        "type": "object",
        "required": ["email", "tier", "consent"],
        "properties": {
          "email": { "type": "string", "format": "email", "maxLength": 254, "description": "Bare addr-spec (no display name); stored lowercased" },
          "tier": { "type": "string", "enum": ["pro", "api", "enterprise"] },
          "interests": {
            "type": "array",
            "maxItems": 10,
            "items": { "type": "string", "maxLength": 40, "enum": ["live_map", "congestion", "port_calls", "alerts", "api", "sanctions", "forecasts", "embeds", "multi_port", "other"] }
          },
          "ports": {
            "type": "array",
            "maxItems": 10,
            "items": { "type": "string", "pattern": "^[A-Za-z]{2}[A-Za-z2-9]{3}$" },
            "description": "GAP-19 (optional): ports the signup wants covered — any well-formed UN/LOCODE (case-insensitive, stored uppercased, de-duplicated); else 422 VALIDATION. Ranked in GET /api/v1/admin/report port_demand."
          },
          "company": { "type": ["string", "null"], "maxLength": 120 },
          "role": { "type": ["string", "null"], "maxLength": 80 },
          "use_case": { "type": ["string", "null"], "maxLength": 1000 },
          "locale": { "type": "string", "maxLength": 35, "description": "BCP 47 tag, e.g. en-GB" },
          "consent": { "type": "boolean", "const": true, "description": "REQUIRED true, else 422 VALIDATION" },
          "website": { "type": "string", "description": "Honeypot — leave empty; non-empty → 202 and nothing stored" },
          "ref": { "type": ["string", "null"], "maxLength": 200, "description": "Page / UTM the signup came from" }
        }
      },
      "CollectBeacon": {
        "type": "object",
        "required": ["event", "path"],
        "properties": {
          "event": { "type": "string", "description": "pageview | page_leave | a FunnelEvent name" },
          "path": { "type": "string", "maxLength": 200, "description": "Path only; any query/fragment is stripped server-side" },
          "referrer": { "type": "string", "description": "document.referrer, first pageview of a page load only; reduced to its host, self-referrals dropped" },
          "utm_source": { "type": "string", "maxLength": 100 },
          "utm_medium": { "type": "string", "maxLength": 100 },
          "utm_campaign": { "type": "string", "maxLength": 100 },
          "utm_term": { "type": "string", "maxLength": 100 },
          "utm_content": { "type": "string", "maxLength": 100 },
          "screen_w": { "type": "integer", "description": "Bucketed server-side" },
          "screen_h": { "type": "integer" },
          "lang": { "type": "string", "description": "navigator.language; reduced to the primary subtag" },
          "tz": { "type": "string", "description": "IANA time zone; mapped to a country code (zone.tab), never stored" },
          "engaged_ms": { "type": "integer", "minimum": 0, "description": "page_leave only; capped at 6 h" },
          "webdriver": { "type": "boolean", "description": "navigator.webdriver (automation), kept as a filter tag" },
          "props": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Custom events only; allowlisted keys tier, port" }
        }
      },
      "FunnelEvent": {
        "type": "object",
        "required": ["name", "path"],
        "properties": {
          "name": {
            "type": "string",
            "enum": ["pricing_view", "cta_click", "waitlist_view", "waitlist_submit", "embed_copy", "api_key_created", "share_click", "port_view", "vessel_view", "signup_view", "provenance_open", "upgrade_prompt_shown", "upgrade_prompt_clicked", "coverage_view", "flows_view"]
          },
          "tier": { "type": ["string", "null"], "enum": ["free", "pro", "api", "enterprise", null] },
          "port": { "type": ["string", "null"], "description": "Enabled UN/LOCODE (case-insensitive) or null" },
          "path": { "type": "string", "maxLength": 200, "description": "Validated, not stored" }
        }
      },
      "TractionReport": {
        "type": "object",
        "properties": {
          "generated_at": { "type": "string", "format": "date-time" },
          "since": { "type": "string", "format": "date" },
          "days": { "type": "integer" },
          "waitlist": {
            "type": "object",
            "properties": {
              "total": { "type": "integer" },
              "by_tier": { "type": "object", "additionalProperties": { "type": "integer" } },
              "per_day": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": { "date": { "type": "string", "format": "date" }, "tier": { "type": "string" }, "count": { "type": "integer" } }
                }
              },
              "interests": { "type": "object", "additionalProperties": { "type": "integer" }, "description": "All-time histogram" }
            }
          },
          "funnel": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "date": { "type": "string", "format": "date" },
                "name": { "type": "string" },
                "tier": { "type": "string", "description": "\"\" = none" },
                "port": { "type": "string", "description": "\"\" = none" },
                "count": { "type": "integer" }
              }
            }
          },
          "users": { "type": "object", "properties": { "registered": { "type": "integer" } } },
          "api_keys": { "type": "object", "properties": { "active": { "type": "integer" }, "total": { "type": "integer" } } },
          "websocket": {
            "type": "object",
            "properties": {
              "connected_clients": { "type": "integer" },
              "peak_clients_since_start": { "type": "integer", "description": "High-water mark since the API process started (resets on restart)" }
            }
          }
        }
      },
      "JWK": {
        "type": "object",
        "description": "RFC 7517 RSA public key",
        "required": ["kty", "kid", "alg", "use", "n", "e"],
        "properties": {
          "kty": { "type": "string", "const": "RSA" },
          "kid": { "type": "string", "description": "First 8 hex chars of SHA-256(PKIX public key) (§11.8)" },
          "alg": { "type": "string", "const": "RS256" },
          "use": { "type": "string", "const": "sig" },
          "n": { "type": "string", "description": "Modulus, base64url without padding" },
          "e": { "type": "string", "description": "Exponent, base64url without padding" }
        }
      },
      "ApiKey": {
        "type": "object",
        "properties": {
          "id": { "type": "integer" },
          "prefix": { "type": "string" },
          "created_at": { "type": "integer" },
          "last_used_at": { "type": ["string", "null"], "format": "date-time", "description": "GAP-11: last authenticated use (RFC 3339 UTC); null = never used. Persisted at most once per minute per key" },
          "plan": { "type": "string", "enum": ["free", "pro", "team"], "description": "GAP-11: local plan label of the owning account (tier api → team); selects the daily quota. No billing is attached" }
        }
      },
      "DevReport": {
        "type": "object",
        "description": "GAP-15 / GAP-19 developer report — aggregates only (no emails, account ids or IPs). Rates are 0..1 rounded to 4 decimals and null when the denominator is 0.",
        "properties": {
          "generated_at": { "type": "string", "format": "date-time" },
          "since": { "type": "string", "format": "date" },
          "days": { "type": "integer" },
          "funnel": {
            "type": "object",
            "properties": {
              "by_day": {
                "type": "array",
                "items": { "type": "object", "properties": { "date": { "type": "string", "format": "date" }, "name": { "type": "string" }, "count": { "type": "integer" } } },
                "description": "Funnel events per UTC day (summed over tier and port)"
              },
              "totals": { "type": "object", "additionalProperties": { "type": "integer" }, "description": "Window totals per event name" }
            }
          },
          "activation": {
            "type": "object",
            "description": "Cohort: accounts that signed up in the window. signup → first API key → first successful (status < 400) API-key call",
            "properties": {
              "signups": { "type": "integer" },
              "created_key": { "type": "integer" },
              "first_call": { "type": "integer" },
              "signup_to_key": { "type": ["number", "null"] },
              "key_to_first_call": { "type": ["number", "null"] },
              "signup_to_first_call": { "type": ["number", "null"] },
              "median_hours_to_key": { "type": ["number", "null"] },
              "median_hours_to_first_call": { "type": ["number", "null"] }
            }
          },
          "api_usage": {
            "type": "object",
            "properties": {
              "calls_by_day": { "type": "array", "items": { "type": "object", "properties": { "date": { "type": "string", "format": "date" }, "count": { "type": "integer" } } } },
              "calls_total": { "type": "integer" },
              "keys_used": { "type": "integer", "description": "Distinct API keys with ≥ 1 metered call in the window" }
            }
          },
          "waitlist": {
            "type": "object",
            "properties": {
              "total": { "type": "integer" },
              "by_tier": { "type": "object", "additionalProperties": { "type": "integer" } },
              "signups_in_window": { "type": "integer" }
            }
          },
          "port_demand": {
            "type": "array",
            "description": "Requested ports from waitlist signups (all time), most requested first, ties by UN/LOCODE",
            "items": { "type": "object", "properties": { "port": { "type": "string" }, "requests": { "type": "integer" } } }
          },
          "provenance": {
            "type": "object",
            "description": "Provenance-open rate = provenance_open events / (port_view + vessel_view events) in the window",
            "properties": { "opens": { "type": "integer" }, "page_views": { "type": "integer" }, "open_rate": { "type": ["number", "null"] } }
          }
        }
      },
      "LatLng": { "type": "object", "required": ["lat", "lng"], "properties": { "lat": { "type": "number" }, "lng": { "type": "number" } } },
      "Port": {
        "type": "object",
        "required": ["locode", "name", "stage", "portwatch_id", "country", "timezone", "centroid", "bbox", "anchorages"],
        "properties": {
          "locode": { "type": "string", "examples": ["SGSIN"] },
          "name": { "type": "string" },
          "stage": { "$ref": "#/components/schemas/PortStage" },
          "portwatch_id": { "type": "string", "description": "IMF PortWatch portid (GAP-16 validation key); empty when none", "examples": ["port1114"] },
          "country": { "type": "string", "description": "ISO 3166-1 alpha-2" },
          "timezone": { "type": "string", "description": "IANA timezone", "examples": ["Asia/Singapore"] },
          "centroid": { "$ref": "#/components/schemas/LatLng" },
          "bbox": {
            "type": "array",
            "items": { "type": "number" },
            "minItems": 4,
            "maxItems": 4,
            "description": "[latMin, lonMin, latMax, lonMax]; ≤ 1 deg² so it is a valid WS subscribe window"
          },
          "anchorages": {
            "type": "array",
            "description": "Defined anchorage fences (data/geofences.json) with their outlines",
            "items": {
              "type": "object",
              "required": ["name", "polygon"],
              "properties": {
                "name": { "type": "string" },
                "polygon": {
                  "type": "array",
                  "description": "Outline as a [lng, lat] ring (WGS 84, GeoJSON order)",
                  "items": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 }
                }
              }
            }
          }
        }
      },
      "Weather": {
        "type": "object",
        "required": ["wave_height_m", "wave_period_s", "wind_speed_kn", "wind_dir_deg", "observed_ms"],
        "properties": {
          "wave_height_m": { "type": ["number", "null"] },
          "wave_period_s": { "type": ["number", "null"] },
          "wind_speed_kn": { "type": ["number", "null"], "description": "Knots (Open-Meteo wind_speed_unit=kn)" },
          "wind_dir_deg": { "type": ["number", "null"] },
          "observed_ms": { "type": "integer", "description": "Observation time, epoch ms" }
        }
      },
      "PortSummary": {
        "type": "object",
        "required": [
          "locode",
          "as_of",
          "vessels_tracked",
          "vessels_at_anchor",
          "congestion_index",
          "median_wait_min",
          "port_calls_24h",
          "alerts_open",
          "weather"
        ],
        "properties": {
          "locode": { "type": "string" },
          "as_of": { "type": "string", "format": "date-time" },
          "vessels_tracked": {
            "type": "integer",
            "description": "Distinct MMSIs with a position inside the port bbox in the last 15 minutes"
          },
          "vessels_at_anchor": { "type": "integer", "description": "Open anchorage-phase port calls of the port" },
          "congestion_index": {
            "type": ["number", "null"],
            "description": "Latest stored port-level CI (port_metrics anchorage \"_port\"); null before the first hourly row"
          },
          "median_wait_min": {
            "type": ["number", "null"],
            "description": "Latest stored port-level median wait (trailing 24 h departures); null when unknown"
          },
          "port_calls_24h": { "type": "integer", "description": "Port-call phases that arrived in the last 24 h" },
          "alerts_open": {
            "type": "integer",
            "description": "Port alerts in the last 24 h not acknowledged by the caller (anonymous: all)"
          },
          "weather": { "oneOf": [{ "$ref": "#/components/schemas/Weather" }, { "type": "null" }] },
          "provenance": {
            "type": "object",
            "description": "PRD GAP-9 (additive): provenance of the inferred tile fields. Methods: detector_queue_rank, detector_median_24h, detector_open_calls, detector_distinct_arrivals, ais_positions_15m. Confidence by port stage (live high, pilot medium, calibrating 0); a null value carries confidence 0 and as_of null.",
            "required": ["congestion_index", "median_wait_min", "vessels_at_anchor", "port_calls_24h", "vessels_tracked"],
            "properties": {
              "congestion_index": { "$ref": "#/components/schemas/ProvenanceMeta" },
              "median_wait_min": { "$ref": "#/components/schemas/ProvenanceMeta" },
              "vessels_at_anchor": { "$ref": "#/components/schemas/ProvenanceMeta" },
              "port_calls_24h": { "$ref": "#/components/schemas/ProvenanceMeta" },
              "vessels_tracked": { "$ref": "#/components/schemas/ProvenanceMeta" }
            }
          }
        }
      },
      "TrackPoint": {
        "type": "object",
        "required": ["t", "lat", "lng", "sog", "cog"],
        "properties": {
          "t": { "type": "integer", "description": "epoch ms" },
          "lat": { "type": "number" },
          "lng": { "type": "number" },
          "sog": { "type": ["number", "null"] },
          "cog": { "type": ["number", "null"] }
        }
      },
      "EtaBucket": {
        "type": "object",
        "required": ["bucket", "sample_count", "median_minutes", "p80_minutes"],
        "properties": {
          "bucket": { "type": "string", "enum": ["passenger", "cargo", "tanker", "other"] },
          "sample_count": { "type": "integer" },
          "median_minutes": { "type": ["number", "null"] },
          "p80_minutes": { "type": ["number", "null"] }
        }
      },
      "WaitBucket": { "type": "object", "properties": { "label": { "type": "string" }, "count": { "type": "integer" } } }
    }
  }
}
