Encoding ISPS security levels in port-call records

Encoding ISPS security levels in port-call records is how a port operator turns a Contracting Government’s MARSEC declaration into structured data that gate systems, escort desks, screening lanes, and routing APIs can enforce without a human relaying a phone call. The International Ship and Port Facility Security (ISPS) Code defines three levels — SL1 normal, SL2 heightened, SL3 exceptional — but a level is only operationally useful once it lives inside the port-call record as an attributed, timestamped, append-only field that downstream services read as their single source of truth. This page specifies the enum, the validated transition history, the Declaration of Security handshake between ship and port facility, and the projection that hands the level to the security-boundary routing layer.

Architecture Alignment

This task belongs to the Regulatory Compliance Reference area within the Core Maritime Architecture & Taxonomy framework, and it is deliberately upstream of enforcement. The port-call record owns the authoritative security level — who declared it, under whose authority, and from what moment it takes effect — while Maritime Security Boundary Setup owns the runtime decision of whether a given vessel track or gate event satisfies that level. Keeping those responsibilities separate is what prevents a routing endpoint from silently inventing a MARSEC state: the routing API in Implementing ISPS security zones in routing APIs never sets a level, it only enforces the one this record has already declared. The same level also gates the berth and pilot logic in Port Call Workflow Design, so a single escalation to SL2 propagates consistently to every consumer instead of drifting between systems.

ISPS security level: source, record state, enforcement, and DoS handshake A Contracting Government or Port Facility Security Officer declares a security level, which is stored as SL1 normal, SL2 heightened, or SL3 exceptional in the port-call security block alongside an audit trail of actor, authority, and timestamp. The recorded level drives three enforcement effects — gate access control, escort and monitoring, and screening intensity — and is projected to the security-boundary routing layer as a MARSEC gate. The level in effect also triggers a Declaration of Security handshake between the ship's SSO and the port facility's PFSO, whose completion status feeds the routing context. SOURCE PORT-CALL RECORD ENFORCEMENT ROUTING Contracting Government / PFSO declares level Port-call security block SL1 · normal SL2 · heightened SL3 · exceptional audit: actor · authority · timestamp Gate access control credential + purpose Escort & monitoring restricted-area sponsor Screening intensity search rate scales with SL Security-boundary routing layer MARSEC gate SL in effect Declaration of Security (DoS) Ship — SSO Ship Security Officer Port facility — PFSO Port Facility Security Officer dos_on_file
The security level flows from a Contracting Government or PFSO declaration into the port-call security block, drives gate, escort, and screening enforcement, and is projected to the routing layer as a MARSEC gate; the level in effect also governs the ship/facility Declaration of Security handshake.

Prerequisites & Environment Setup

The security block is a pure data layer — no geometry, no network — so it targets Python 3.11+ for X | None unions and modern IntEnum semantics, with pydantic for the validated schema and structlog for the audit trail. Install the stack:

pip install "pydantic>=2.6" "structlog>=24.1" "pytest>=8.0"

Structured JSON logging is not optional here: every level change and every DoS signature is evidence a port state control officer can demand under SOLAS Chapter XI-2, so bare print() or unstructured strings are unacceptable. Configure structlog once at process start to emit JSON:

import structlog

structlog.configure(
    processors=[
        structlog.contextvars.merge_contextvars,
        structlog.processors.add_log_level,
        structlog.processors.TimeStamper(fmt="iso", utc=True),
        structlog.processors.JSONRenderer(),
    ],
)
log = structlog.get_logger("isps.security_level")

The service reads a small set of environment variables so the same code runs for any facility without hard-coding identity:

Variable Purpose Example
FACILITY_UNLOCODE UN/LOCODE of the port facility this instance serves NLRTM
PFSO_ID Identifier of the on-duty Port Facility Security Officer PFSO-NLRTM-014
DOS_REQUIRED_FROM_SL Level at or above which a DoS is mandatory 2
ISPS_ZONE_VERSION Pinned zone-registry version stamped onto every routing projection 2026.07.0
AUDIT_LOG_STREAM Destination stream for immutable level-change events isps.audit

Step-by-step Implementation

Step 1 — Model the level enum and the officers who may set it

The three ISPS levels are ordinal, not categorical: SL3 is stricter than SL2 which is stricter than SL1, so an IntEnum lets >= comparisons drive enforcement directly. Per ISPS Code Part A the Contracting Government sets the level for a port facility; a PFSO applies the facility’s measures for that level, a CSO and SSO run the ship side, but none of them may unilaterally invent a national security level. Encode both the level and the roles so authority can be validated.

from __future__ import annotations

from enum import Enum, IntEnum

import structlog

log = structlog.get_logger("isps.security_level")


class SecurityLevel(IntEnum):
    """ISPS Code Part A / SOLAS XI-2 levels. Integer ordering is load-bearing:
    a higher value is a stricter regime, so `>=` gates access and screening."""

    SL1 = 1  # normal — baseline protective measures maintained at all times
    SL2 = 2  # heightened — additional measures for a period of heightened risk
    SL3 = 3  # exceptional — further measures for a probable or imminent threat

    @property
    def label(self) -> str:
        return {1: "normal", 2: "heightened", 3: "exceptional"}[self.value]


class SecurityRole(str, Enum):
    """The ISPS officers with standing to set or attest a level."""

    CONTRACTING_GOVERNMENT = "CG"   # sets the level a facility operates at
    PFSO = "PFSO"                   # Port Facility Security Officer
    CSO = "CSO"                     # Company Security Officer
    SSO = "SSO"                     # Ship Security Officer


def authorised_to_set(role: SecurityRole) -> bool:
    """Only a Contracting Government, or a PFSO acting on its directive, may
    change the level a facility operates at. A ship-side officer never can."""
    return role in (SecurityRole.CONTRACTING_GOVERNMENT, SecurityRole.PFSO)

Step 2 — Encode the port-call security block with a validated transition history

A level is worthless without provenance, so the block stores an append-only history of records rather than a single mutable field — the current level is simply the head of that list, and every prior level stays reconstructable for an audit. Any jump is legal under the Code (a facility can go straight from SL1 to SL3 when a threat is imminent), but a “change” to the identical level is a redundant record, history must be chronologically ordered, and any move to SL3 must carry a documented reason. Transitions are validated on construction so a malformed block never reaches the enforcement layer.

from datetime import datetime

from pydantic import BaseModel, Field, model_validator
import structlog

log = structlog.get_logger("isps.port_call")


class SecurityLevelRecord(BaseModel):
    """One immutable audit entry: the level, who set it, under what authority."""

    level: SecurityLevel
    set_by_role: SecurityRole
    set_by_id: str = Field(min_length=1)          # e.g. "PFSO-NLRTM-014"
    authority: str = Field(min_length=1)          # Contracting Government or facility
    effective_at: datetime
    reason: str | None = None

    @model_validator(mode="after")
    def _check_authority(self) -> "SecurityLevelRecord":
        if not authorised_to_set(self.set_by_role):
            raise ValueError(f"{self.set_by_role.value} may not set a security level")
        if self.level is SecurityLevel.SL3 and not self.reason:
            raise ValueError("SL3 (exceptional) requires a documented reason")
        if self.effective_at.tzinfo is None:
            raise ValueError("effective_at must be timezone-aware (UTC)")
        return self


class PortCallSecurityBlock(BaseModel):
    """The security block embedded in a port-call record. `current` derives from
    the head of an append-only history so every level ever in force is auditable."""

    port_call_id: str
    vessel_imo: int = Field(ge=1000000, le=9999999)          # IMO number is 7 digits
    facility_unlocode: str = Field(min_length=5, max_length=5)  # UN/LOCODE
    history: list[SecurityLevelRecord] = Field(min_length=1)

    @property
    def current(self) -> SecurityLevel:
        return self.history[-1].level

    @model_validator(mode="after")
    def _validate_transitions(self) -> "PortCallSecurityBlock":
        for prev, nxt in zip(self.history, self.history[1:]):
            if nxt.level == prev.level:
                raise ValueError("a security-level change must alter the level")
            if nxt.effective_at < prev.effective_at:
                raise ValueError("level history must be chronologically ordered")
        log.info(
            "security_block_validated",
            port_call_id=self.port_call_id,
            vessel_imo=self.vessel_imo,
            current_level=int(self.current),
            transitions=len(self.history) - 1,
        )
        return self

    def apply_change(self, record: SecurityLevelRecord) -> "PortCallSecurityBlock":
        """Return a new block with the change appended; the original is untouched."""
        log.warning(
            "security_level_changed",
            port_call_id=self.port_call_id,
            from_level=int(self.current),
            to_level=int(record.level),
            set_by=record.set_by_id,
            authority=record.authority,
        )
        return self.model_copy(update={"history": [*self.history, record]})

Step 3 — Complete the Declaration of Security between ship and port facility

A Declaration of Security (DoS) records the security measures a ship and a port facility each agree to apply during their interface. Under SOLAS Chapter XI-2 Regulation 10 a Contracting Government determines when one is required; in practice it is mandatory at SL3, common at SL2, and requested whenever the ship and facility operate at mismatched levels. Model it as a two-signatory handshake — the SSO signs for the ship, the PFSO for the facility — and refuse to treat SL3 work as authorised until both signatures are on file.

from datetime import datetime

from pydantic import BaseModel, Field, model_validator
import structlog

log = structlog.get_logger("isps.dos")


class DeclarationOfSecurity(BaseModel):
    """Agrees the measures each party applies during a ship/port interface."""

    port_call_id: str
    level: SecurityLevel
    ship_signatory: str = Field(min_length=1)      # SSO identifier
    facility_signatory: str = Field(min_length=1)  # PFSO identifier
    ship_signed_at: datetime | None = None
    facility_signed_at: datetime | None = None
    valid_until: datetime

    @property
    def is_complete(self) -> bool:
        return self.ship_signed_at is not None and self.facility_signed_at is not None

    @model_validator(mode="after")
    def _enforce_dos_requirement(self) -> "DeclarationOfSecurity":
        if self.level is SecurityLevel.SL3 and not self.is_complete:
            log.error("dos_incomplete_at_sl3", port_call_id=self.port_call_id)
            raise ValueError("a completed DoS is required before working at SL3")
        return self


def dos_required(block: PortCallSecurityBlock, floor: SecurityLevel) -> bool:
    """Facility policy sets the floor (DOS_REQUIRED_FROM_SL); SL3 always qualifies."""
    return block.current >= floor

Step 4 — Project the level to the security-boundary routing layer

The routing API must never read the whole port-call record — it needs a small, immutable projection carrying only what a MARSEC gate decides on. Publish that projection on every level change so the routing layer enforces the current level rather than a stale one, and stamp the pinned zone version so the decision is reproducible. The marsec_level field is the integer value of SecurityLevel, which is exactly why the enum is ordinal.

from pydantic import BaseModel, Field
import structlog

log = structlog.get_logger("isps.routing_bridge")


class RoutingSecurityContext(BaseModel):
    """The minimal projection the routing layer consumes. The port-call block is
    the source of truth; the routing API enforces, it never sets, a level."""

    vessel_imo: int
    facility_unlocode: str
    marsec_level: int = Field(ge=1, le=3)   # mirrors ISPS SL1/SL2/SL3 as 1/2/3
    dos_on_file: bool
    zone_version: str


def project_to_routing(
    block: PortCallSecurityBlock,
    dos: DeclarationOfSecurity | None,
    zone_version: str,
) -> RoutingSecurityContext:
    ctx = RoutingSecurityContext(
        vessel_imo=block.vessel_imo,
        facility_unlocode=block.facility_unlocode,
        marsec_level=int(block.current),
        dos_on_file=bool(dos and dos.is_complete),
        zone_version=zone_version,
    )
    log.info(
        "routing_context_published",
        vessel_imo=ctx.vessel_imo,
        marsec_level=ctx.marsec_level,
        dos_on_file=ctx.dos_on_file,
        zone_version=zone_version,
    )
    return ctx

Edge Cases & Carrier Deviations

Symptom Root cause Fix
Routing enforces SL1 while facility is at SL2 level raised but the projection never re-published Call project_to_routing on every apply_change; key the routing cache on len(history) so a stale context is invalidated
Vessel worked at SL3 without a DoS DoS requirement not gated at the record layer DeclarationOfSecurity refuses to validate at SL3 unless is_complete; block gate access until then
Level change accepted from an SSO authority not checked _check_authority rejects any role outside CG/PFSO
Two levels share one effective timestamp clock skew across submitting systems Require tz-aware UTC and strictly increasing effective_at in _validate_transitions
History silently rewritten a record mutated in place Keep history append-only and treat every SecurityLevelRecord as immutable evidence
SL3 stand-down leaves no trail level lowered without attribution The lowering record carries its own set_by_id, authority, and reason, exactly like an escalation

A specific deviation worth calling out: a port facility that locally tightens measures on its own initiative and records that as an SL2 “escalation.” Under SOLAS Chapter XI-2 the Contracting Government sets the security level; a PFSO may apply additional measures but cannot manufacture a national level. If both cases collapse into the same field, an audit can no longer tell a government-directed SL2 from facility-initiated hardening. Keep the authority field honest — record Contracting Government only when the level genuinely came from one, and log facility-initiated measures without overstating them as a level change.

Verification & Testing

Assert the three behaviours most likely to fail closed: an unauthorised setter is rejected, a same-level “change” is rejected, and the routing projection carries the head level plus DoS status. Use fixed UTC timestamps so the transition ordering is deterministic.

from datetime import datetime, timezone

import pytest

from maritime.isps import (
    DeclarationOfSecurity, PortCallSecurityBlock, SecurityLevel,
    SecurityLevelRecord, SecurityRole, project_to_routing,
)

UTC = timezone.utc


def _rec(level: SecurityLevel, when: datetime, role=SecurityRole.PFSO, reason=None):
    return SecurityLevelRecord(
        level=level, set_by_role=role, set_by_id="PFSO-NLRTM-014",
        authority="Port of Rotterdam", effective_at=when, reason=reason,
    )


def test_ship_officer_cannot_set_level() -> None:
    with pytest.raises(ValueError):
        _rec(SecurityLevel.SL2, datetime(2026, 7, 1, tzinfo=UTC), role=SecurityRole.SSO)


def test_same_level_transition_rejected() -> None:
    a = _rec(SecurityLevel.SL1, datetime(2026, 7, 1, 8, tzinfo=UTC))
    b = _rec(SecurityLevel.SL1, datetime(2026, 7, 1, 9, tzinfo=UTC))
    with pytest.raises(ValueError):
        PortCallSecurityBlock(
            port_call_id="PC-1", vessel_imo=9321483,
            facility_unlocode="NLRTM", history=[a, b],
        )


def test_projection_mirrors_current_level_and_dos() -> None:
    block = PortCallSecurityBlock(
        port_call_id="PC-1", vessel_imo=9321483, facility_unlocode="NLRTM",
        history=[
            _rec(SecurityLevel.SL1, datetime(2026, 7, 1, 8, tzinfo=UTC)),
            _rec(SecurityLevel.SL3, datetime(2026, 7, 1, 10, tzinfo=UTC),
                 role=SecurityRole.CONTRACTING_GOVERNMENT, reason="credible threat"),
        ],
    )
    dos = DeclarationOfSecurity(
        port_call_id="PC-1", level=SecurityLevel.SL3,
        ship_signatory="SSO-42", facility_signatory="PFSO-NLRTM-014",
        ship_signed_at=datetime(2026, 7, 1, 10, 5, tzinfo=UTC),
        facility_signed_at=datetime(2026, 7, 1, 10, 6, tzinfo=UTC),
        valid_until=datetime(2026, 7, 2, tzinfo=UTC),
    )
    ctx = project_to_routing(block, dos, zone_version="2026.07.0")
    assert ctx.marsec_level == 3
    assert ctx.dos_on_file is True

A passing escalation emits one JSON audit line per event; the change looks like {"event": "security_level_changed", "port_call_id": "PC-1", "from_level": 1, "to_level": 3, "set_by": "PFSO-NLRTM-014", "authority": "Port of Rotterdam", "level": "warning", "timestamp": "..."}. Assert on the structured keys, never on a formatted string.

Frequently Asked Questions

Can a port drop straight from SL3 to SL1 in one record?

Yes. ISPS levels are not a ratchet — a Contracting Government can stand a facility down from exceptional straight to normal once the threat clears, just as it can escalate SL1 to SL3 directly. The transition validator only rejects a “change” to the identical level and out-of-order timestamps. What it does enforce is attribution: the stand-down record carries its own set_by_id, authority, and effective_at, so the audit trail shows exactly who lowered the level and when, which is what a port state control review will ask for.

When is a Declaration of Security actually required?

A completed DoS is mandatory before working at SL3, and it is required whenever a Contracting Government directs one under SOLAS XI-2 Regulation 10 — commonly at SL2, when a ship and facility operate at mismatched levels, or for a ship/port interface that poses a heightened risk. The dos_required helper reads a facility floor from DOS_REQUIRED_FROM_SL so a port can mandate a DoS from SL2 upward, while the DeclarationOfSecurity model hard-fails validation at SL3 without both the SSO and PFSO signatures on file.

Should the routing layer ever set the security level itself?

No. The port-call security block is the single source of truth; the routing layer only enforces the level projected to it. If the routing API could set a level, two systems would compete to define the same MARSEC state and drift apart within a shift. Instead, project_to_routing publishes an immutable RoutingSecurityContext on every change, stamped with the pinned zone version, so the enforcement decision in the routing endpoint is reproducible from the projection alone.

Up: Regulatory Compliance Reference — the reference area governing how maritime regulation is encoded as validated data.