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.
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.
Related
- Maritime Security Boundary Setup — the discipline that enforces the level this record declares as typed zone policy.
- Implementing ISPS security zones in routing APIs — the runtime endpoint that consumes the projected
marsec_level. - Modeling SOLAS VGM declarations as pydantic schemas — a sibling compliance model in the same reference area.
- Generating IMO FAL forms as structured data — the arrival-formality dataset that shares the same port-call record.
- Port Call Workflow Design — the state machine whose berth and pilot logic reads the current level.
Up: Regulatory Compliance Reference — the reference area governing how maritime regulation is encoded as validated data.