Reconciling CODECO and COREOR status events

A container terminal broadcasts two very different kinds of truth about the same box, and they rarely arrive in the order events actually happened. Reconciling CODECO and COREOR status events means folding UN/EDIFACT CODECO gate-in/gate-out and discharge/load reports together with COREOR container release orders into one coherent, monotonic container state — resolving out-of-order delivery, duplicate retransmissions, and the structural conflict between a physical movement and an authorization to move. This page specifies the reconciler that decides which message wins, when a release must precede a gate-out, and what a canonical container record looks like once both feeds agree.

Architecture Alignment

This reconciler is a fusion node inside the Container Status Mapping Rules discipline, which sits under the Container Tracking & AIS Event Synchronization domain. Where the sibling Mapping ISO container status codes to internal states resolver turns one raw qualifier into one enum value, this page consumes several of those resolved tokens across two message families and merges them over time into a single record per container. It assumes the interchange has already been de-enveloped and segmented upstream — the same UNB/UNH stripping and delimiter discipline documented for IFCSUM EDI Message Parsing — so every event reaching the reconciler is already a clean, UTC-stamped assertion. The hard problem here is not parsing bytes; it is deciding, when a gate-out report and a release order disagree about whether a box was allowed to leave, which one the canonical state must believe.

CODECO and COREOR reconciliation merge into a canonical container state A COREOR release order asserting an authorization and a stream of CODECO gate-in, gate-out, discharge and load movements asserting physical facts both flow into a central event reconciler. The reconciler deduplicates on message reference, orders events by authority then by event timestamp, and enforces the invariant that a release must precede a gate-out. A consistent merge resolves to a canonical container state carrying the presence lifecycle and a release-authorized flag; a conflict such as a missing release or an out-of-order gate-out resolves to an ON_HOLD anomaly that is flagged and audited. authorization physical fact consistent conflict COREOR release order · authorizes pickup CODECO gate-in/out · discharge/load Event reconciler 1 · dedup by message ref 2 · order by authority 3 · then event timestamp 4 · release ▸ gate-out invariant Canonical state IN_TERMINAL · LOADED DISCHARGED · GATED_OUT release_authorized flag ON_HOLD anomaly unauthorized / out-of-order flag + audit trail
Two assertion streams — COREOR authorizations and CODECO physical movements — merge in a reconciler that deduplicates, orders by authority then timestamp, and enforces the release-before-gate-out invariant, yielding either a canonical state or an ON_HOLD anomaly.

Prerequisites & Environment Setup

The reconciler is pure Python with no network dependency; it takes already-parsed events and returns a canonical record. Target Python 3.11+ for enum.StrEnum, IntEnum ordering, and X | None unions.

python -m venv .venv && source .venv/bin/activate
pip install "structlog>=24.1" "pytest>=8.0"

Structured JSON logging is mandatory: every reconciliation decision — which event won, which was a duplicate, which tripped the invariant — must be replayable during a customs or terminal dispute, so a bare print() is unacceptable. Configure structlog once at process start:

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("container.reconcile")

The reconciler reads a few tunables from the environment so behaviour is diffable across deployments rather than hard-coded:

Variable Purpose Example
CLOCK_SKEW_S Timestamp tie tolerance between terminal clocks and the VAN 2
RELEASE_TTL_H How long a COREOR authorization stays valid before it expires 72
DEDUP_WINDOW_H Window over which a repeated message reference collapses to a no-op 168
CODE_MAP_REGISTRY Path to the checked-in qualifier→state map shared with the sibling resolver /etc/maritime/status_code_map.json

Step-by-step Implementation

Each step is runnable in isolation and composes into a single EventReconciler. Movement and release qualifiers arrive in the STS segment (UN/EDIFACT data element 8249, status of the event); equipment identity comes from EQD, the event time from DTM, and the document reference from BGM/RFF.

Step 1 — Model each message as a typed, authority-ranked assertion

The two message families assert incompatible kinds of fact, and conflating them is the root of most reconciliation bugs. A CODECO reports that something physically happened — a box crossed the gate, or a crane discharged it — and is authoritative for the container’s presence. A COREOR is a carrier instruction that authorizes the terminal to release a box to a haulier; it changes permission, never location. Encode that difference as an explicit Authority rank so the merge can reason about precedence rather than guessing from message order.

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import datetime
from enum import IntEnum, StrEnum


class MessageType(StrEnum):
    CODECO = "CODECO"   # container gate-in/gate-out and discharge/load report
    COREOR = "COREOR"   # container release order


class Authority(IntEnum):
    """Precedence when two events contend for the same canonical fact."""
    RELEASE = 10        # COREOR: authorization dimension only
    STOW = 20           # CODECO discharge/load: vessel-side movement
    GATE = 30           # CODECO gate-in/gate-out: landside physical truth


@dataclass(frozen=True, slots=True)
class StatusEvent:
    container_id: str          # ISO 6346 equipment id from EQD
    message_type: MessageType
    qualifier: str             # raw STS / DE 8249 token, pre-normalisation
    event_ts: datetime         # DTM, timezone-aware UTC
    message_ref: str           # BGM/RFF document reference — the dedup key
    authority: Authority = field(default=Authority.GATE)

Step 2 — Map raw qualifiers to an internal lifecycle vocabulary

Before merging, each raw 8249 token resolves to a closed internal state through the same precedence discipline described in Mapping ISO container status codes to internal states — exact match first, never a greedy guess. Here the vocabulary is deliberately narrow: the presence lifecycle a gate/stow movement can drive, plus a distinct RELEASE_AUTHORIZED marker that a COREOR sets on the authorization axis rather than the presence axis.

class Lifecycle(StrEnum):
    IN_TERMINAL = "IN_TERMINAL"          # gated in, resting in the yard
    DISCHARGED = "DISCHARGED"            # lifted off the vessel
    LOADED = "LOADED"                    # lifted onto the vessel
    RELEASE_AUTHORIZED = "RELEASE_AUTHORIZED"  # COREOR granted, may leave by road
    GATED_OUT = "GATED_OUT"              # departed the terminal by road
    ON_HOLD = "ON_HOLD"                  # conflict or unauthorized movement
    UNKNOWN = "UNKNOWN"                  # unresolved qualifier — fail closed


QUALIFIER_MAP: dict[str, Lifecycle] = {
    "GATEIN": Lifecycle.IN_TERMINAL,
    "GATEOUT": Lifecycle.GATED_OUT,
    "DISCH": Lifecycle.DISCHARGED,
    "LOAD": Lifecycle.LOADED,
    "RELEASE": Lifecycle.RELEASE_AUTHORIZED,
}


def resolve(qualifier: str) -> Lifecycle:
    token = qualifier.strip().upper().replace("-", "").replace("_", "")
    state = QUALIFIER_MAP.get(token, Lifecycle.UNKNOWN)
    if state is Lifecycle.UNKNOWN:
        log.warning("unresolved_qualifier", qualifier=qualifier)
    return state

Step 3 — Merge events by container id with dedup, precedence, then timestamp

The core fold groups every event for one container, collapses retransmissions on message_ref, and then orders what remains. Ordering is a two-key sort: primarily by event_ts, so the physical timeline is honoured, and secondarily by authority, so that when two events share a timestamp within the configured clock skew — a gate-out and a stale stow report stamped the same second by different subsystems — the higher-authority gate movement is applied last and wins. Sorting is what makes the reconciler robust to out-of-order arrival: an event delivered late still lands in its correct temporal slot rather than overwriting newer truth.

import os
from collections import defaultdict

CLOCK_SKEW_S: float = float(os.environ.get("CLOCK_SKEW_S", "2"))


def order_events(events: list[StatusEvent]) -> list[StatusEvent]:
    seen: set[str] = set()
    deduped: list[StatusEvent] = []
    for ev in events:
        if ev.message_ref in seen:
            log.info("duplicate_collapsed", container_id=ev.container_id,
                     message_ref=ev.message_ref)
            continue
        seen.add(ev.message_ref)
        deduped.append(ev)
    # Primary key: real-world time. Tie-break: authority (gate beats stow beats release).
    return sorted(deduped, key=lambda e: (e.event_ts, int(e.authority)))


def group_by_container(events: list[StatusEvent]) -> dict[str, list[StatusEvent]]:
    grouped: dict[str, list[StatusEvent]] = defaultdict(list)
    for ev in events:
        grouped[ev.container_id].append(ev)
    return {cid: order_events(evs) for cid, evs in grouped.items()}

Step 4 — Fold to a canonical state and enforce the release-before-gate-out invariant

The expected sequence is unambiguous: a COREOR release order should arrive, and be effective, before the CODECO gate-out it authorizes. Reconciling the real feed means tolerating the times it does not — a gate-out that a haulier physically performed before the carrier’s release order was transmitted, or a release order that shows up hours after the box already left. The fold tracks the authorization axis and the presence axis separately, and only promotes a GATED_OUT movement to canonical when a live, unexpired release backs it. A gate-out with no valid release resolves to ON_HOLD — the physical fact is recorded, but it is flagged as unauthorized rather than silently accepted.

from dataclasses import dataclass, field
from datetime import timedelta

RELEASE_TTL_H: float = float(os.environ.get("RELEASE_TTL_H", "72"))


@dataclass
class CanonicalState:
    container_id: str
    presence: Lifecycle = Lifecycle.UNKNOWN
    release_authorized: bool = False
    release_ts: datetime | None = None
    last_ts: datetime | None = None
    anomalies: list[str] = field(default_factory=list)


class EventReconciler:
    """Merge CODECO movements and COREOR orders into one container state."""

    def reconcile(self, container_id: str, events: list[StatusEvent]) -> CanonicalState:
        state = CanonicalState(container_id=container_id)
        for ev in order_events(events):
            resolved = resolve(ev.qualifier)
            state.last_ts = ev.event_ts

            if resolved is Lifecycle.RELEASE_AUTHORIZED:
                state.release_authorized = True
                state.release_ts = ev.event_ts
                log.info("release_recorded", container_id=container_id,
                         message_ref=ev.message_ref)
                continue

            if resolved is Lifecycle.GATED_OUT:
                fresh = self._release_is_fresh(state, ev.event_ts)
                if not (state.release_authorized and fresh):
                    state.presence = Lifecycle.ON_HOLD
                    state.anomalies.append("gate_out_without_valid_release")
                    log.error("unauthorized_gate_out", container_id=container_id,
                              event_ts=ev.event_ts.isoformat())
                    continue

            if resolved is not Lifecycle.UNKNOWN:
                state.presence = resolved

        log.info("reconciled", container_id=container_id, presence=state.presence,
                 release_authorized=state.release_authorized,
                 anomaly_count=len(state.anomalies))
        return state

    @staticmethod
    def _release_is_fresh(state: CanonicalState, gate_ts: datetime) -> bool:
        if state.release_ts is None:
            return False
        return gate_ts - state.release_ts <= timedelta(hours=RELEASE_TTL_H)

Edge Cases & Carrier Deviations

Symptom Root cause Fix
Gate-out committed with no release on file COREOR batched hours behind the physical move Fold to ON_HOLD, keep the movement, reconcile when the late release lands
Release order for an already-departed box COREOR retransmitted after gate-out (expected-sequence inversion) Idempotent: sets the authorization flag but never re-opens a closed gate-out
Same movement counted twice VAN redelivery after an AS2/SFTP outage Collapse on RFF message reference within DEDUP_WINDOW_H
Gate-out timestamp precedes its gate-in Terminal clock in local port time, VAN in UTC Normalise to UTC at ingestion; order by event_ts, not arrival order
Release accepted for the wrong box COREOR keyed on booking, not on the EQD equipment id Match release to container on ISO 6346 id, never on booking reference alone
Stale movement overwrites newer state Late CODECO applied by arrival order Sort by (event_ts, authority) so a late event lands in its temporal slot

A specific deviation worth calling out is the non-standard STS qualifier: some terminals emit a proprietary gate-out token (for example OUTGT or a numeric movement code) that is absent from UN/EDIFACT 8249. The resolve function returns UNKNOWN for it rather than guessing the nearest movement, which correctly withholds the gate-out from the presence axis until the code map is extended — the same fail-closed posture the sibling ISO-code resolver takes, and the reason a release can never be inferred from an unrecognised token.

Verification & Testing

The tests that matter prove the invariant holds in both directions: an authorized gate-out reaches GATED_OUT, and an unauthorized or out-of-order one is quarantined to ON_HOLD. Use fixed UTC fixtures so the ordering and TTL logic are exercised deterministically.

from datetime import datetime, timezone

import pytest

UTC = timezone.utc


def ev(cid, mtype, qual, minute, ref, auth):
    return StatusEvent(cid, mtype, qual, datetime(2026, 7, 17, 10, minute, tzinfo=UTC),
                       ref, auth)


@pytest.fixture
def reconciler() -> EventReconciler:
    return EventReconciler()


def test_release_before_gate_out_commits(reconciler: EventReconciler) -> None:
    events = [
        ev("MSKU1234565", MessageType.CODECO, "GATEIN", 0, "R1", Authority.GATE),
        ev("MSKU1234565", MessageType.COREOR, "RELEASE", 5, "R2", Authority.RELEASE),
        ev("MSKU1234565", MessageType.CODECO, "GATEOUT", 9, "R3", Authority.GATE),
    ]
    state = reconciler.reconcile("MSKU1234565", events)
    assert state.presence is Lifecycle.GATED_OUT
    assert state.release_authorized is True
    assert state.anomalies == []


def test_gate_out_without_release_is_held(reconciler: EventReconciler) -> None:
    events = [ev("MSKU1234565", MessageType.CODECO, "GATEOUT", 9, "R9", Authority.GATE)]
    state = reconciler.reconcile("MSKU1234565", events)
    assert state.presence is Lifecycle.ON_HOLD
    assert "gate_out_without_valid_release" in state.anomalies


def test_duplicate_message_ref_collapses(reconciler: EventReconciler) -> None:
    dup = ev("MSKU1234565", MessageType.CODECO, "GATEIN", 0, "R1", Authority.GATE)
    state = reconciler.reconcile("MSKU1234565", [dup, dup])
    assert state.presence is Lifecycle.IN_TERMINAL

A passing unauthorized-gate-out case emits one structured line the audit pipeline indexes directly: {"event": "unauthorized_gate_out", "container_id": "MSKU1234565", "event_ts": "2026-07-17T10:09:00+00:00", "level": "error", ...}. Assert on the structured keys, never on a formatted message string.

Frequently Asked Questions

Why does a CODECO gate-out outrank a COREOR release when they conflict?

Because they are authoritative for different things. A CODECO gate-out reports a physical fact — the box left the terminal — and no release order can undo a movement that already happened. A COREOR is authoritative only for permission. So the reconciler never lets a release change presence; it records the authorization on a separate axis and uses it to decide whether an observed gate-out was legitimate. When a gate-out has no live release behind it, the movement is still recorded but flagged ON_HOLD for investigation rather than accepted as a clean departure.

How does the reconciler stay correct when messages arrive out of order?

By sorting on the event’s own DTM timestamp, not on arrival order, and tie-breaking on authority within the configured clock skew. A late COREOR that lands after its gate-out is folded into its correct temporal slot: it sets the authorization flag but cannot re-open a closed gate-out, so the outcome is identical to the in-order case. This is why the fold is expressed over a sorted event list rather than as a running mutation of whatever message happened to arrive most recently.

What collapses a duplicate, and what is the dedup key?

The BGM/RFF document reference carried on every message is the dedup key. When a value-added network replays hours of buffered traffic after an outage, the same release order or gate-out report arrives again with the same reference; the reconciler skips any reference it has already applied within DEDUP_WINDOW_H. Keying on the document reference rather than on the payload means a benign retransmission collapses to a no-op while a genuinely new movement — a different reference — is always processed.

Up: Container Status Mapping Rules — the correlation discipline that consumes this reconciled, gated container state.