Skip to content

Behavioural Contract

iliyan85 edited this page Aug 9, 2026 · 7 revisions

Behavioural Contract

This page is an explanatory map of the tested Python reference behavior. The canonical document in the main repository is normative:

Read BEHAVIORAL_CONTRACT.md

If this Wiki and the canonical contract differ, use the current production code and canonical contract, then correct the Wiki. Future implementation changes must update tests and the contract together.

Scope

The contract covers observable behavior for:

  • ingress frame production and compatibility-event acceptance;
  • supported AIS NMEA extraction;
  • multipart assembly and lifecycle outcomes;
  • processor-owned TAG metadata;
  • single and multipart deduplication;
  • secure replay, pending-session, active-session, and per-owner nonce state;
  • routing snapshot timing and compiled numeric target matching;
  • bounded runtime queues, processing admission, and backpressure;
  • processor-instance ownership and reset lifecycle;
  • the processor-to-egress boundary;
  • immutable output bytes and ordered numeric dispatch; and
  • process-local runtime supervision and pull-based statistics.

It is not an AIS semantic decoder, storage or analytics specification, spoof-detection specification, native interface, or ABI.

Processing pipeline

UDP / UDPSEC producers
    |
immutable IngressFrame
    |
private bounded ingress queues
    1024 items per input
    |
ingress fan-in
    |
direct frame / one legacy IngressEvent adapter
    |
bounded processing admission
       1024 items
    |
capacity permit -> routing snapshot -> numeric match
    |
ProcessingWorkItem(frame, ProcessingSnapshot)
    |
serial processor stage
    |
PythonDataPlaneProcessor.process(frame, snapshot)
    |
bytes-native scan spans and ParsedSentence metadata
    |
feed_parsed_outcome()
    |
processor-owned TAG metadata selection
    |
global or target-scoped deduplication
    |
complete ordered OutputBatch
    |
ProcessorOutput(message: bytes, numeric target_ids)
    |
bounded one-batch egress handoff and completion barrier
    |
egress stage
    |
sequential Forwarder.send_to_ids() dispatch

Shared lifecycle invariant

Unless an explicitly documented wall-clock protocol rule applies, process-local TTL state is:

Live while age < ttl and expired when age >= ttl.

Exact duplicates do not refresh dedup entries, multipart groups, handshake replay records, or data-nonce records. Unique multipart progress and fully validated active secure traffic are the relevant refresh cases; pending-session lifetime is not refreshed.

See State, Lifecycle, and Limits for the comparative owner model.

Frame and scanning boundaries

Built-in UDP and UDPSEC producers enqueue immutable IngressFrame objects. Each fan-in reader takes a queue item, accepts a direct frame by object identity, or adapts one legacy IngressEvent when raw_line satisfies isinstance(raw_line, str). Invalid compatibility events and unsupported queue items are ignored before processing admission, snapshot acquisition, or processor invocation, and later queued items continue normally. Fan-in does no scanning, parsing, assembly, deduplication, or sending.

The bytes-native scanner accepts supported VDM and VDO talker/family combinations in input order. It requires checksum-field syntax of * followed by two hexadecimal characters but does not verify checksum arithmetic. A TAG block is associated only when its closing backslash immediately precedes the sentence.

Scanner results contain immutable half-open spans into the original frame. The scanner does not decode or copy sentence or TAG text. ParsedSentence retains the frame and spans while fragment fields and relevant TAG metadata are parsed once. The Python assembler later materializes sentence strings, so this is not an end-to-end zero-copy or fully bytes-native data plane.

Multipart identity and outcomes

The public identity is:

AssemblyKey = tuple[str, str, str, int]
# (assembler identity, sequential ID, channel, declared total)

The runtime assembler identity is the ingress peer IP and port. It is distinct from routing source_id. TAG g and the current fragment ordinal are not AssemblyKey fields.

Production calls feed_parsed_outcome() with fragment metadata already retained by ParsedSentence. The legacy string APIs feed() and feed_outcome() remain valid compatibility surfaces. Parsed and string entry points converge on the same lifecycle implementation and distinguish:

Status Meaning
INVALID Input cannot enter a valid assembly lifecycle.
SINGLE One valid sentence is immediately ready without multipart state or clock use.
LIMIT_EXCEEDED A valid multipart declaration exceeds the configured fragment limit.
PENDING Unique progress was accepted but the group remains incomplete.
DUPLICATE The exact sentence already occupies that ordinal.
CONFLICT Different content occupied the ordinal, invalidating the generation.
COMPLETE Every ordinal is present and output is materialized in order.

Fragments may arrive fully out of order. Exact duplicates are idempotent and do not refresh group lifetime. Unique progress does refresh it. A conflict removes the live generation and does not seed a replacement from the conflicting arrival.

discarded_keys reports expiry, conflict, and capacity removals in deterministic order. Completion consumes its own context separately; cleanup_expired() and reset() also return keys for external owners to consume. Together, these surfaces let processor-owned output metadata follow assembler lifecycle boundaries without reading assembler internals.

Blank sequential IDs remain supported. Fragments from separate physical transmissions can therefore form a synthetic group when all other identity fields collide within one live window; completion is not proof of common physical origin.

The Python assembler supports optional max_fragments_per_group and max_pending_groups. Current service wiring leaves both as None; they are not YAML configuration keys.

Processor-owned TAG metadata

The assembler owns multipart fragment groups. The long-lived PythonDataPlaneProcessor separately owns multipart output TAG s, c, and g context under the same AssemblyKey. These roles are deliberately separate.

Every assembler-reported discarded key clears all three contexts before metadata on the current arrival is considered. Completion consumes them even when no route matches or deduplication suppresses all output.

  • TAG s: the non-empty completion-arrival value can override earlier cached ingress metadata; final output selection still follows configured station/input policy.
  • TAG c: multipart selection uses the minimum valid decimal observation. A duplicate may lower but not raise it. Multipart c:0 is preserved; single-sentence c:0 retains the server-time compatibility fallback.
  • TAG g: candidates are non-empty decimal strings compared exactly. One observed value can be preserved; none or disagreement produces one generated ID for the completed group. TAG g does not define assembler identity.

See Multipart NMEA Assembly and TAG Handling.

Group-atomic deduplication

A single message uses its exact extracted sentence as the logical key. A multipart message uses the ordinal-ordered tuple of exact extracted sentences. Ingress TAG metadata is not part of either key.

The multipart tuple is decided once before any fragment is emitted:

  • legacy mode uses one global scope;
  • routing mode uses one independent scope per numeric EgressTargetId; and
  • ingress source identity does not add another scope for a target.

The Python Deduplicator supports optional max_entries. Current service wiring uses None, so the running service does not impose that capacity through this object and YAML does not expose it.

See Deduplication.

Secure local state

SecureState is the process-local owner for:

  • verified handshake replay records;
  • pending secure sessions;
  • active secure sessions; and
  • accepted data nonces privately scoped to each pending or active session.

Network policy is checked before secure-state clocks, cryptography, cleanup, or mutation. Allowed packets use one monotonic observation for local lifecycle decisions. Wall time remains separate for handshake freshness, pong timestamps, and diagnostics.

An authenticated ClientHello and ServerHello install pending state with separate C2S and S2C AES-GCM owners; they do not replace a same-address active session. Promotion requires a pending-C2S DATA packet containing a strict sequence-zero confirmation ping for the authenticated station. Its nonce is admitted before promotion. Promotion transfers both directional owners and the pending nonce set to active state, replaces a same-address active session as one transition, and returns a sequence-zero pong under the promoted S2C owner.

Replay and nonce duplicates do not refresh retention. Pending lifetime is fixed from creation and is not refreshed by traffic. Active sessions are touched only by promotion or fully validated encrypted active ping/NMEA traffic; liveness has no unencrypted control path. Secure replay, pending, active, and per-owner nonce state are independently hard-bounded, traffic-cleaned, non-durable, and lost at restart.

See UDPSEC Security and State for verified limits, replacement rules, and trust boundaries.

Routing snapshot boundary

Configuration, RouteDefinition.to, status, control responses, and CLI output remain string-facing. Internally, Forwarder assigns every configured destination a dense zero-based numeric EgressTargetId, including unnamed legacy destinations. These IDs are process-local positions, not stable configuration or control identifiers.

Routing candidates resolve all names and compile a complete immutable numeric target-only plan before installation. A failed compilation leaves the active snapshot unchanged. Production calls match_target_ids(frame.source_id) without name lookup, preserving route and target declaration order and keeping only the first occurrence of a repeatedly matched target.

Only after shared processing capacity is reserved does fan-in read one immutable routing-state snapshot and derive:

ProcessingSnapshot(
    routing_generation: int,
    deduplication_mode: DeduplicationMode,
    target_ids: tuple[EgressTargetId, ...],
)

ProcessingSnapshot is a target-only processor view. It contains no RoutingTable, compiled route program, mapping, transport, queue, Future, or asyncio object. A missing or disabled table selects GLOBAL deduplication and all numeric forwarder IDs. An enabled table selects PER_TARGET deduplication and one numeric match. All supported sentences from that frame use the same resolved tuple.

Fan-in binds the exact frame and ProcessingSnapshot into one immutable ProcessingWorkItem. A waiting frame has captured no routing state and may observe a concurrent replacement; an admitted work item stays fixed. Routing generation is observational and does not reset processing state. The Processing and Routing Model owns the queue topology, backpressure, and admission explanation; the canonical contract owns exact unsupported-item and empty-output edge cases.

The two empty-target cases are distinct:

  • GLOBAL + (): global deduplication and normal processor effects still run. A unique message is formatted with an explicit empty target tuple, but no datagram is dispatched.
  • PER_TARGET + (): no target matched, so there is no global deduplication admission, the output builder is not called, and no ProcessorOutput is emitted. Assembly and multipart metadata cleanup still run normally.

For the exact edge cases and state effects, use the canonical contract linked at the top of this page.

Statistics and reset surfaces

Deduplication, assembly, and secure state expose frozen point-in-time statistics objects. Reading them:

  • does not read a clock;
  • does not perform cleanup;
  • does not expose mutable state; and
  • cannot change an earlier snapshot.

Counters keep lifecycle reasons separate. These detailed component-state objects support regression and future differential-conformance testing; they are distinct from the runtime statistics interface and are not exposed as a complete operator-facing lifecycle-state endpoint.

One main() invocation constructs one long-lived PythonDataPlaneProcessor for the serial processor stage. It owns retained processing state and cumulative processor metrics. Its synchronous, fail-fast, non-transactional reset() returns an immutable report but is not a control-protocol or aismixerctl operation. See State, Lifecycle, and Limits for the ownership model and the canonical contract for exact reset ordering.

Campaign F's separate operator metrics are process-local, in-memory, non-durable snapshots. See Runtime Observability for their ownership and interpretation.

Processor and egress boundary

For every admitted ProcessingWorkItem, the processor stage calls DataPlaneProcessor.process(frame, snapshot) exactly once. PythonDataPlaneProcessor completes all synchronous parsing, assembly, multipart metadata, deduplication, TAG construction, and output construction before returning one complete ordered OutputBatch.

The frozen public result values are:

ProcessorOutput(
    message: bytes,
    target_ids: tuple[EgressTargetId, ...],
)

OutputBatch(
    outputs: tuple[ProcessorOutput, ...],
)

Each ProcessorOutput represents one fully formatted sentence. Its message is exact immutable bytes, and its targets are explicit ordered numeric IDs; an empty target tuple is valid. OutputBatch preserves output order, may be empty, and contains no completion Future, queue, transport, or runtime state.

core.output_builder.build_output_bytes() is the sole production output builder. It delegates TAG formatting and checksum generation to the canonical string-facing meta writer, appends exactly one CRLF to the complete TAG-plus-NMEA sentence, and performs one UTF-8 encoding. Encoding is once per emitted sentence, including once for each multipart fragment; a multipart group is never concatenated into one payload.

An empty OutputBatch completes locally. A non-empty batch crosses a private process-local handoff whose queue capacity is one batch envelope. The processor stage waits on that batch's completion barrier and does not begin the next processor call until egress has completed the final awaited local output operation. Later work may already be admitted and carry a bound snapshot. The barrier follows batch contents, so a formatted GLOBAL output with target_ids=() still crosses it.

Egress processes outputs sequentially in stored order. Every production output uses Forwarder.send_to_ids(output.target_ids, output.message), and the forwarder dispatches selected destinations sequentially. It performs no payload encoding or normalization and passes the same bytes object to each selected UDP transport. Debug decoding changes only a local display view. Forwarder.send() and named Forwarder.send_to() remain compatibility APIs, not production orchestration paths.

If the processor call fails, no batch crosses to egress and no later frame is processed. A send failure prevents later dispatch in the batch and likewise prevents processing of a later frame, but it does not roll back already completed processor effects or reconstruct the OutputBatch. The private runtime completion signal is an ordering and failure barrier, not an ingress acknowledgement or network-delivery acknowledgement. There is no transactional delivery, retry, rollback, replay, or recovery guarantee after partial output.

Process-local runtime supervision

One process-local supervisor owns every essential top-level task: plain UDP and UDPSEC producers, ingress fan-in, the processor stage, and the egress stage. Tasks are created lazily from role-named specifications; partial task-creation failure closes the rejected coroutine and cleans already-created tasks. Fan-in privately owns and supervises its per-input reader tasks, and an empty fan-in remains idle until cancellation. The first real task failure is propagated; an unexpected normal return or internal task cancellation becomes a role-named runtime failure. External cancellation cancels and awaits all owned tasks, resolves pending completion state, and is re-raised as CancelledError. Sibling tasks are cancelled and awaited, and their outcomes are retrieved before termination propagates. UDPSEC closes its owned socket after bind failure, runtime failure, or cancellation.

This is single-process asyncio task supervision. It does not define coordinator or worker processes, cross-process supervision, IPC, automatic service restart, delivery retry, or durable recovery. A systemd unit may independently restart the whole service according to its unit policy; that policy is outside both task supervision and the data-plane processor contract.

Campaign F and later architecture

Campaign F completed Worker Readiness inside the current single Python process. It established bounded stage queues and backpressure, capacity-safe snapshot/work-item admission, processor-instance state ownership and reset, ordered bounded egress handoff, runtime traffic accounting, immutable pull-based metrics, and read-only operator statistics through aismixerctl.

These are explicit bounded and observable in-process boundaries that can later become worker boundaries. Campaign F did not introduce a coordinator, ingress, processor, or egress worker process, multiprocessing, IPC, cross-process supervision, distributed metrics, native implementation, bindings, or ABI.

A later process-architecture phase may introduce the coordinator, dedicated workers, IPC, routing-snapshot distribution, cross-process failure handling, restart or recovery policy, and metrics aggregation. A later native phase may implement a processor and bindings behind the established contracts. These are separate future tracks.

Native conformance

Python remains the sole implemented production and behavioural reference. Campaign C made the ingress frame, byte-span scanner, parsed metadata, and assembler entry boundary explicit. Campaign D added the synchronous DataPlaneProcessor contract, PythonDataPlaneProcessor as the sole production and reference processor, and the explicit fan-in, processor, completion-barrier, and egress runtime boundary. Campaign E added the immutable numeric target registry and compiled matching, exact output bytes, OutputBatch, and unified numeric egress. Campaign F made runtime handoffs bounded and observable and made processor ownership, reset, and admission-time snapshot binding explicit. A future implementation can be compared through ordered processor outputs, TAG metadata, routing targets, dedup decisions, lifecycle outcomes and discarded keys, explicit no-output cases, and contract-relevant statistics.

No native processor, C or C++ API, ABI, binding technology, benchmark, or performance claim is defined. See Native-Ready Reference Foundation.

Architecture history

Campaign A consolidated observable Python behavior and differential-test seams. Campaign B made state ownership, clocks, TTLs, limits, lifecycle outcomes, and immutable statistics explicit. Campaign C introduced immutable ingress frames, bytes-native scanning, immutable spans, parse-once fragment and TAG metadata, and the parsed assembler production path while preserving the established lifecycle and output behavior.

Campaign D then established the synchronous processor contract, the sole Python reference processor, complete ordered output batches, the processor-to-egress completion barrier, explicit runtime stages, and process-local fail-fast supervision.

Campaign E established dense numeric egress identity, compiled numeric target-only production matching, exact immutable bytes in each ProcessorOutput, OutputBatch as the public ordered result, and unified production dispatch through send_to_ids(). It preserved the sequential ordering and completion barrier established earlier.

Campaign F established bounded queue/backpressure semantics, immutable processing work-item handoff, processor reset ownership, and process-local runtime observability. It preserved the one-process Python runtime.

No campaign changes the rule that current production code and the canonical contract are the source of truth.

See Architecture Evolution.

Clone this wiki locally