ducknng protocol specification

This document is binding for ducknng protocol work. New transport or data-plane behavior should either conform to this specification or update it deliberately before implementation proceeds. The purpose of the specification is to keep ducknng framed as a DuckDB-backed SQL and RPC server whose transport is NNG, whose tabular payload codec is Arrow IPC through nanoarrow C, and whose public capability surface is declared through a predefined manifest.

The protocol is intentionally layered. NNG defines the current socket pattern and delivery model, the ducknng envelope defines how requests and replies are named and correlated semantically, the payload format defines whether a message body contains Arrow IPC or JSON, and the method contract defines what a named operation means. Keeping those layers separate is required because it lets the project evolve without sliding back into ad hoc operation-specific binary blobs. The transport-facing interface is explicitly modeled over nanonext as the ergonomic reference point for NNG req/rep usage, while the thin-envelope plus Arrow-IPC-payload RPC direction is explicitly informed by projects such as mangoro. In particular, transport selection should be autodetected from the URL scheme rather than expressed through transport-specific function proliferation: the same operation-oriented API already accepts inproc://, ipc://, tcp://, tls+tcp://, ws://, and wss:// on the NNG side, with the HTTP family documented separately in docs/transports.md.

The frame envelope

The central design rule is that the envelope must stay small and generic. The project should not keep inventing one-off binary layouts for each operation. Arrow IPC is the default payload format for method inputs and outputs whenever the content is tabular. JSON is the preferred payload format for manifest and lightweight control metadata. nanonext is the explicit ergonomic model for the transport-facing interface, but it is not part of the wire contract itself. Any version-sensitive DuckDB or NNG behavior must remain isolated behind dedicated compatibility or shim files.

The current target envelope is a compact frame containing an explicit version byte, a message type byte, a little-endian 32-bit control word, little-endian name length, little-endian error length, and little-endian payload length, followed by the UTF-8 method name, the UTF-8 error string when present, and the payload bytes. The low 24 bits of the control word are semantic flags. Its high byte is the protocol status: it is zero on manifest, call, result, and event frames, and carries a documented nonzero status on newly emitted error frames. Error frames produced before the status assignment used a zero high byte; decoders expose those as UNSPECIFIED rather than confusing them with successful status. The exact C struct layout can change so long as the logical wire contract does not drift. All integers are little-endian and all names and error strings are UTF-8. The version field is mandatory because the envelope itself must be versioned independently of individual methods. One carrier message contains exactly one frame; bytes after the counted payload are invalid.

The message type space is intentionally small. There is a manifest request type for discovery, a call type for ordinary RPC requests, a result type for successful replies, an error type for failures, and a reserved event type for future push-style traffic. A manifest request is not method-specific in the same sense as a normal call, but it still participates in the same top-level framing model. A result may carry Arrow IPC bytes or JSON depending on the method contract. An error always carries a human-readable UTF-8 error string and a protocol status in the envelope and may later carry structured payload data as an extension. The assigned statuses are INVALID, NOT_FOUND, BUSY, SQL_ERROR, ARROW_ERROR, INTERNAL, CANCELLED, TLS_ERROR, UNAUTHORIZED, and DISABLED; UNSPECIFIED is a decode-only representation for legacy error frames whose high status byte was zero.

In-band errors

SQL-facing errors should stay in-band for expected network, protocol, and remote-method failures. Structured helpers return one row with ok = false and an error string. Framed raw RPC helpers return a valid DUCKNNG_RPC_ERROR frame for local setup and transport failures when no server reply frame exists; callers can inspect it with ducknng_decode_frame(...) or ducknng_frame_error_text(...). Throwing a DuckDB scalar/table error is reserved for misuse of extension-local state, invalid non-framed socket operations, or internal failures where no meaningful row or frame can be constructed.

The low 24 flag bits are semantic modifiers rather than hidden encodings. The stable meaning covers whether a reply contains rows, whether it contains metadata, whether the payload is JSON or direct Arrow IPC stream bytes, whether a stream has reached end-of-stream, whether a session has been opened or closed, and whether a cancellation has been acknowledged. The high status byte is not returned as part of the semantic flag mask. Methods must document which flags they accept and emit. Clients should be able to understand a reply by combining the message type, protocol status, method name, and declared meaning of flags in the manifest. If a future reply-metadata envelope is introduced, it should be a generic negotiated extension of this layer rather than a mislabeled substitute for Arrow IPC.

Transport patterns

REQ/REP is the required initial transport pattern. It is the correct pattern for manifest discovery, unary exec, session opening, batch fetch, session close, cancel, and most control methods. One request produces one reply, but that reply may either contain immediate rows, immediate metadata, or session-opening information for later fetches. Large result sets should not be forced through a one-shot unary reply when session semantics are more appropriate. On the current SQL client surface this is exposed both as one-shot raw request helpers and as req-style socket handles that still execute one roundtrip per explicit SQL request call. Those client helpers are transport-agnostic at the function level: the URL scheme chooses the underlying NNG transport.

AIO is a client-side execution model rather than a new wire feature. In nanonext terms, an aio is one future-like handle for one pending NNG operation backed by nng_aio. ducknng follows that meaning. SQL-visible aio helpers therefore wrap ordinary transport operations instead of inventing new envelope types, split-phase wire semantics, or background push behavior. The generic socket layer remains the substrate: ducknng_request_raw_aio(...) and ducknng_request_socket_raw_aio(...) create local handles for one pending framed request, ducknng_open_query_raw_aio(...), ducknng_fetch_query_raw_aio(...), ducknng_close_query_raw_aio(...), and ducknng_cancel_query_raw_aio(...) do the same for the four session-lifecycle operations, ducknng_send_socket_raw_aio(...) and ducknng_recv_socket_raw_aio(...) do the same for the broader raw socket layer, ducknng_ncurl_aio(...) does the same for one unary HTTP/HTTPS request, the additive ducknng_ncurl_stream_open_aio(...) / ducknng_ncurl_stream_recv_aio(...) pair exposes one response-head operation followed by cancellable raw-body receive operations over an explicitly closed stream handle, ducknng_aio_ready(...) reports single-handle readiness, ducknng_aio_wait(...) waits for any handle in a list without collecting it, ducknng_aio_collect(...) returns collected raw-frame terminal results, ducknng_aio_collect_decoded(...) is the additive decoded-frame convenience wrapper over those same terminal frame rows, ducknng_ncurl_aio_collect(...) returns collected unary raw HTTP status/header/body terminal results, the two stream collectors return response-head and de-framed raw-body results respectively, and ducknng_aio_cancel(...) / ducknng_aio_drop(...) control aio lifecycle while ducknng_ncurl_stream_close(...) controls the connection-owning stream lifecycle. For URL-launched request aio helpers, launch stays operation-oriented and asynchronous across carriers: NNG URLs start dialing in NNG’s background retry path, HTTP/HTTPS URLs use NNG’s asynchronous HTTP client path, and both still terminate as one collected raw reply frame or one terminal error. Expected setup and launch failures on SQL-visible aio launch helpers return an immediate terminal error aio when a runtime exists, so callers inspect the error with ducknng_aio_status(...), ducknng_aio_collect(...), or ducknng_ncurl_aio_collect(...) instead of handling a DuckDB exception. The stable async contract is raw-result-first: framed RPC aio collection returns reply frames for later explicit decoding, and low-level HTTP aio collection returns raw HTTP status/header/body rows. The first unary RPC async wrappers therefore remain raw-frame-first: ducknng_get_rpc_manifest_raw_aio(...) and ducknng_run_rpc_raw_aio(...) launch ordinary request/reply method calls asynchronously and later return the same collected raw reply frames that ducknng_decode_frame(...) and the scalar frame accessors (ducknng_frame_version(...), ducknng_frame_type(...), ducknng_frame_status(...), ducknng_frame_flags(...), ducknng_frame_type_name(...), ducknng_frame_name(...), ducknng_frame_payload(...), ducknng_frame_payload_text(...), ducknng_frame_error_text(...), ducknng_frame_end_of_stream(...)) already know how to inspect. Structured async wrappers stay additive and layered over the same one-operation handle model; they must not become a second background job or streaming protocol. timeout_ms on an aio launch bounds the lifetime of that pending network operation; it is distinct from the later wait_ms argument to wait and collect helpers, which only controls how long one SQL call waits before returning.

The synchronous low-level socket helpers use the same in-band error principle. ducknng_open_socket(...), ducknng_dial_socket(...), ducknng_listen_socket(...), ducknng_close_socket(...), ducknng_send_socket_raw(...), ducknng_recv_socket_raw(...), ducknng_subscribe_socket(...), and ducknng_unsubscribe_socket(...) return a single result struct with ok, error, nullable nng_error, nullable nng_error_message, socket_id, payload, and url. Ducknng validation failures set ok = false and error; failures returned by NNG also set the NNG numeric error code and nng_strerror() text. This keeps low-level transport primitives usable inside larger SQL workflows without forcing expected transport failures through DuckDB exceptions.

PUB/SUB is reserved for future server-originated fanout at the manifest-declared RPC method layer. It is a separate protocol family and should not be collapsed into the unary SQL RPC path. When introduced there, it should be used for event emission, progress notifications, server status broadcasts, or similar push-style traffic. This does not contradict the existing generic socket layer: raw socket primitives for PUB/SUB, PUSH/PULL, SURVEYOR/RESPONDENT, BUS, and related patterns already exist as transport-facing SQL helpers. The caution here is about higher-level protocol meaning, not about the low-level socket surface.

Method families

The initial method families are discovery and control, optional unary SQL execution, and session-based query execution. The always-on built-ins are manifest, handshake, query_prepare, query_open, fetch, close, and cancel. query_prepare accepts exactly one statement and returns a zero-row Arrow stream carrying the prepared result schema; it never executes the statement. exec is opt-in and returns unary rows or execution metadata. query_open starts the session path for large or incremental results. Names such as server_info remain reserved until they exist in the registry and manifest.

The query session family

The session query family is a fixed four-method contract rather than an open-ended streaming grab bag. query_open starts a query session and returns session-opening metadata plus the session identifier, a session-scoped bearer capability named session_token, and an opaque result_handle that remains stable for the life of the opened result. fetch consumes that session incrementally and returns zero or more Arrow record batches plus explicit end-of-stream signaling. close releases the server-side session explicitly and is successful whether the query is exhausted or not. cancel requests early termination of an in-flight or not-yet-exhausted session and then leaves close as the normal cleanup path unless the server explicitly reports that cancel also closed the session. These names are reserved for the canonical query lifecycle and should not be repurposed for unrelated control traffic.

The request and reply shapes of the session family are fixed at the contract level. query_open accepts an Arrow IPC stream containing exactly one logical request row with non-null UTF-8 sql plus optional batch_rows, batch_bytes, correlation_id, serialization_mode, and params. params is a nullable Arrow struct whose child order binds positional ? parameters; a missing or null tuple means no parameters. A parameterized query_open accepts exactly one SQL statement, so a tuple never has ambiguous cross-statement scope. The same optional tuple is accepted by exec and query_prepare; the handshake and manifest advertise the currently active parameter-capable methods plus the 65,535-parameter limit. Values are bound through DuckDB’s prepared-statement API, never interpolated into SQL text.

A successful query_open reply is JSON control metadata because the primary output is session state: it returns the session identifier, session token, stable result_handle, state, next method, negotiated serialization mode and protocol/schema versions, effective fetch chunk count, and server-owned idle timeout. fetch, close, and cancel accept JSON keyed by both session_id and session_token; each may carry a correlation_id. If the opener had a verified mTLS identity, later session requests require the same identity. fetch returns negotiated row payload bytes or JSON-only terminal metadata. arrow_ipc_stream is the portable default. ducknng_quack_batch carries DuckDB logical types and chunks under the same session state machine. close and cancel return control metadata only.

Quack batch payloads

The ducknng_quack_batch container uses DuckDB BinarySerializer fields but is not a complete Quack protocol message. Its optional schema header is field 1 result_types followed by field 2 result_names; field 4 results is a list. Each result element has the one-byte non-null marker emitted for unique_ptr<DataChunkWrapper>, then a DataChunkWrapper object whose field 300 contains one DataChunk. The chunk uses DuckDB’s field 100 row count, field 101 logical-type list, and field 102 vector-object list, followed by separate DataChunk and DataChunkWrapper object terminators. The marker and wrapper are therefore generated DuckDB framing, not ducknng opcodes. Canonical ducknng output repeats and validates chunk logical types exactly like DataChunk::Serialize; the decoder still accepts payloads emitted by ducknng 0.1.2 that omitted the repeated type list and the wrapper terminator between consecutive chunks. Schema omission on later fetches remains a negotiated ducknng session optimization and is not an upstream Quack message shape.

Within each vector, absent field 90 means FLAT_VECTOR. The decoder also accepts DuckDB’s compressed forms and materializes them into flat C API vectors: field 90 value 2 recursively carries one constant value, value 3 carries field 91’s raw sel_t selection, field 92’s dictionary count, and the dictionary vector, and value 4 carries signed field 91 start and field 92 increment. Dictionary lengths and indexes are checked before selection-copy, constants and dictionaries use DuckDB’s C vector-copy API so recursive values retain ownership correctly, and sequence values are range-checked for DuckDB’s emitted integer sequence types. Dictionary cardinality may not exceed its selected row count, matching DuckDB’s compact serialization. Each decoded chunk is capped at 4,194,304 cumulative materialized values, checked before temporary-vector and list-child allocations so compressed input cannot request unbounded expansion. FSST and unknown vector types fail closed. The pure-C encoder continues to emit flat vectors because the C API does not expose source vector compression state.

Logical-type compatibility is narrower than the set of internal ids present in every DuckDB release. In the pinned v1.5.2 contract, GEOMETRY adds vector field 99 plus optional CRS metadata, and VARIANT has no corresponding public C logical-type/vector API. The codec rejects both rather than relabeling their physical storage as BLOB or STRUCT; docs/types.md is the supported type authority.

Query semantics and client helpers

query_open is a streaming lifecycle, not a read-only privilege. It accepts DuckDB SQL and its descriptor truthfully reports mutates_state = true; multi-statement SQL may execute leading statements before opening the final result. Registering or withholding exec only controls the unary exec method. Hosts that require read-only service must enforce that through the SQL authorizer or the capabilities of the injected DuckDB connection.

The SQL client helpers reflect DuckDB table-function planning. ducknng_query_rpc(...) and ducknng_query_rpc_params(...) need the remote result schema during bind, so DuckDB planning can open and fetch a remote query more than once. They are convenience surfaces for read-only or otherwise idempotent SQL. ducknng_prepare_query(...) and ducknng_prepare_query_params(...) also contact the server during bind, but the server only prepares one statement and returns its schema. ducknng_run_rpc(...) and ducknng_run_rpc_params(...) have a fixed result schema and defer the remote exec call until scan; EXPLAIN does not execute them. That prevents bind-time mutation, but it is not distributed exactly-once delivery: a lost reply can leave the caller uncertain whether the remote statement committed, and retries require application-level idempotency or deduplication.

The current contract deliberately does not promise multiplexed cursors inside one session, server-push streaming, or capability-by-obscurity session ids. One session represents one opened query lifecycle. Every subsequent fetch, close, or cancel call names that session explicitly and proves ownership with the session token returned by query_open. The session id is only a lookup key; the bearer token is the session-scoped capability, and deployments that expose the service beyond a trusted boundary must protect it with an appropriate transport such as TLS.

The upload family

The upload family is the client-to-server mirror of the query family: upload_open, upload_append, upload_commit, and upload_abort stream tuple batches from the client into a server table. It is opt-in and registered explicitly with ducknng_register_upload_methods(), gated like exec, so a service exposes no upload surface unless the host asks for it. upload_open accepts a JSON payload with target_table (required) and an optional mode (v1 supports only append to an existing table; target_table is a simple unquoted [[catalog.]schema.]table identifier). It acquires a session connection, opens a transaction, opens an appender on the target, and returns the same session-opening control shape as query_open (session_id, session_token, state, next_method, idle_timeout_ms, optional correlation_id) but no result_handle, since an upload has no fetchable result. upload_append carries a custom binary body advertised as the ducknng_upload_append payload format: a counted control prefix — [session_id: uint64 little-endian][token_len: uint16 little-endian][token: token_len bytes] — immediately followed by one ducknng_quack_batch. The token is compared over its full counted length (embedded NULs are rejected). The batch column names, order, and types must match the target table exactly; a mismatch is rejected before any row is appended, since the appender appends by ordinal. upload_append replies with JSON {state, rows} giving the running appended row count. upload_commit (JSON keyed by session_id + session_token) flushes the appender and commits the transaction, replying JSON {state:"committed", rows}; upload_abort rolls the transaction back. Abort, idle prune, and service shutdown all roll the transaction back, so a partially uploaded session never persists rows. Upload sessions and query sessions share one table but not one contract: a query control (fetch/close/cancel) against an upload session, or an upload control against a query session, is rejected rather than allowed to consume the wrong family’s handle.

The SQL method family

A SQL method is a named, manifest-visible RPC method whose handler is SQL owned by the server; the caller sends only a JSON object and never supplies SQL. The host registers one with ducknng_register_sql_method(name, handler_sql, request_schema_json[, requires_auth]), and the registry exports it in the sql_method family with JSON request and reply formats, request_schema_json as its request schema, and the host’s requires_auth policy. The dispatcher applies the same admission as for every method — listener TLS mode, peer and IP allowlists, the service SQL authorizer, requires_auth, and the 1 MiB request limit — before the handler runs. The payload must be one UTF-8 JSON object; an empty payload is {}. handler_sql may hold several statements. They run in one transaction on the service’s request connection, and each may bind the payload text through its single parameter. Any failure rolls the transaction back and returns an error frame carrying the DuckDB error text. The reply is the first column of the first row of the last statement: a JSON value passes through, a VARCHAR value becomes a JSON string, and no row or NULL becomes null; other types are rejected so that handlers state their encoding with to_json(). Handler SQL identifies its caller with ducknng_request_subject(), which returns the verified peer identity, any principal and claims mapped by the SQL authorizer, and the effective subject, and which returns no rows outside a request. Registering an existing SQL method name replaces it, and a built-in method cannot be replaced. The runtime retains every registered handler until the database closes, so a request dispatched before a replacement or unregistration completes with the SQL it was dispatched with. SQL methods carry application control data; tabular results belong in exec or the query session family.

Payload formats

Arrow IPC stream payloads are the standard format for method arguments and results when the content is tabular. These payloads must be encoded and decoded with nanoarrow C on the extension side. The payload represents a schema and zero or more record batches, and each method contract defines exactly what fields are expected. Methods may accept a broader range of Arrow schemas than they emit, but emitted output should be normalized to the canonical supported set described in docs/types.md so that clients do not have to guess what they will receive.

A future reply-metadata extension, if one is needed, should be negotiated generically and should apply coherently across the API rather than being introduced as a fake row serializer. That extension would need its own documented capability name, manifest exposure, and tests. Today the advertised row payload modes are arrow_ipc_stream and the Quack-derived ducknng_quack_batch; future additions should be equally explicit.

JSON payloads are allowed for manifest data, capability descriptions, handshake negotiation, and lightweight control metadata. JSON should not be used to carry row data when Arrow IPC is appropriate. The guiding idea is simple: JSON describes the protocol surface and server capabilities, whereas Arrow IPC carries tables, batches, arguments, and results.

TLS configuration

TLS configuration is transport configuration rather than RPC payload. For tls+tcp://, the runtime accepts TLS material either from filesystem paths or directly from in-memory PEM content, because file-only TLS setup is too restrictive for embedded and notebook-style clients. The project therefore provides helper utilities comparable in spirit to nanonext::tls_config() and nanonext::write_cert() for creating development certificates, assembling client/server TLS configuration handles, and passing that configuration through the compatibility layer without inventing transport-specific RPC methods.

Transport adapters and carriers

Transport adapters may change the outer carrier, but they do not change the method contract. The current HTTP and HTTPS adapter exposed through ducknng_start_server(...), ducknng_ncurl(...), and the URL-routed synchronous helpers still carries the same manifest methods and the same payload rules. It does not create parallel method names such as http_exec or http_fetch. manifest remains manifest, exec remains exec, and the query session family remains query_open, fetch, close, and cancel. The concrete carrier details for that first HTTP binding are pinned in docs/http.md.

That invariant is especially important for row delivery. Under the HTTP carrier, query_open still takes the same one-row Arrow IPC control payload, fetch still takes JSON control metadata keyed by session_id plus session_token, fetch still returns either Arrow IPC record-batch bytes or JSON control metadata, and close / cancel still remain JSON control methods. The outer transport may add headers or status codes, but it must not replace Arrow IPC row payloads with ad hoc JSON row serialization or change the session state machine. Existing synchronous request, RPC, and session helpers now accept http:// and https:// through the same operation-oriented API, while the generic socket surface remains specific to NNG patterns.

Sessions

A session is server-owned RPC state associated with a query or other streaming lifecycle. Sessions must be explicit. They have identifiers, they carry a separate owner token, they are created only by methods that declare session behavior, they support explicit close, and they are subject to idle cleanup. Session ownership and lifetime semantics must be clear enough to support multiple concurrent clients without ambiguity. The server should never assume that a single caller or transport connection owns the entire process.

For the query family, the lifecycle state machine is intentionally small and documented up front. query_open creates a session in open state. Successful fetch calls keep the session in open until the server emits end-of-stream, at which point the session moves to exhausted and accepts only close as the normal terminal operation. Successful cancel moves the session to cancelling or directly to cancelled depending on engine timing, and a later close remains valid in either case unless the cancel reply explicitly reports that the session was already closed. close is idempotent from the client’s perspective: repeating it after a successful close may return a not-found style status, but it must never reanimate session state or alter a different session.

The current session contract is control-first and REQ/REP-bound. The server may deliver row data only on direct fetch replies, may return zero rows in a successful fetch, and must not invent background push channels, unsolicited events, or shared server-global cursors to compensate. If the server cannot continue producing rows for an opened session, it returns an error or a terminal control reply on the next fetch or cancel; it does not silently disappear the session.

The protocol assumes multiple clients. Different clients may talk to the same service concurrently, and one client must not corrupt another client’s state. Hidden shared mutable execution state should be avoided when per-session or otherwise explicitly owned query state is possible. The default stable implementation runs service-owned DuckDB SQL through one shared serialized connection, surfaced as server.execution.model = "shared_serialized_connection" in the manifest and execution_model in ducknng_list_servers(), but services may opt into service_serialized_connection or request_connection. Query sessions are explicit RPC state; backend DuckDB temp/session state is shared only within the configured execution lane/connection. Methods must declare whether they are stateless, sessionful, idempotent, or exclusive so that both implementation and client behavior can be reasoned about cleanly. Transport-derived identity and SQL-authorizer output are request context, not envelope fields in the current version; they may authorize descriptors, bind sessions, drive service-level peer admission, and attach a server-side principal/claims object for handlers, but they do not change the frame layout.

Method registry

All public methods must be registered through a method registry. The registry must be able to register one method, register groups of methods in bulk, unregister one method, unregister groups or bundles of methods, look methods up by name, and export the current manifest from registry state. The manifest is therefore derived from the registry rather than maintained as a disconnected hand-written list. That rule is essential because the registry is what makes add, remove, and bulk definition of methods coherent.

Compatibility and versioning

Compatibility and versioning are explicit requirements. The envelope version must always be available, the manifest must expose the protocol version, additions should be additive whenever possible, and removal should normally proceed through deprecation rather than surprise disappearance. Version-sensitive code must remain isolated in compatibility files so the protocol surface does not become entangled with low-level runtime quirks.

Implementation checklist

This document is also the implementation checklist. Before a new protocol feature is merged, its transport pattern must be defined, its envelope usage must be clear, its method must be registered, its manifest entry must exist, its input and output schemas must be declared, its type mapping must be documented in docs/types.md, its concurrency or session behavior must be explained, and its error behavior must be testable and documented. If those conditions are not met, the feature is not yet protocol-ready.

Error surface contract

The error surface is divided by abstraction level. The rule is simple: the lower the abstraction, the more structured the error.

Raw socket and request helpers (ducknng_open_socket, ducknng_dial_socket, ducknng_listen_socket, ducknng_close_socket, ducknng_send_socket_raw, ducknng_recv_socket_raw, ducknng_subscribe_socket, ducknng_unsubscribe_socket, ducknng_request, ducknng_request_socket) return a struct or table row with ok BOOLEAN, error VARCHAR, nng_error INTEGER, and nng_error_message VARCHAR. When an NNG API call fails, nng_error carries the numeric return code and nng_error_message carries nng_strerror() text. When a ducknng validation check fails before any NNG call is made, nng_error and nng_error_message are NULL. This four-field pattern is the gold standard for transport-level primitives.

Async I/O helpers (ducknng_aio_status, ducknng_aio_collect, ducknng_aio_collect_decoded) follow the same rule. When an NNG-level send or receive error ends the aio, nng_error and nng_error_message are populated with the raw NNG error code and its string. The ducknng_aio_collect_decoded macro passes both fields through from the underlying ducknng_aio_collect result without reinterpreting them.

High-level session and RPC helpers (ducknng_open_query, ducknng_fetch_query, ducknng_close_query, ducknng_cancel_query, ducknng_get_rpc_manifest, ducknng_run_rpc) return only ok and error. These helpers involve multi-step transport sequences and envelope decoding before a result is available, so attributing a failure to a single NNG error code is not always accurate or useful. The error text field describes the failure. Callers who need raw NNG codes should use the lower-level primitives directly.

HTTP/HTTPS helpers (ducknng_ncurl, the unary ncurl aio family, and the incremental ncurl stream family) use status INTEGER as the carrier-level error code because the HTTP status code is the semantic equivalent of the NNG error code for that transport. Pre-connection failures remain text-only in the error field. There is no nng_error column on HTTP result rows because HTTP failures do not originate from NNG send/recv calls.

Raw BLOB scalar helpers (ducknng_request_raw, ducknng_request_socket_raw) return a bare BLOB with no structured error fields. Local and transport errors are encoded as a DUCKNNG_RPC_ERROR frame in the BLOB so the result is always parseable with ducknng_decode_frame.

Configuration and lifecycle mutators (service start/stop, TLS registration, registry and method-auth changes, allowlist and authorizer registration) throw a DuckDB exception for missing IDs, invalid arguments, or internal state violations. These are programmer or configuration errors for which in-band rows would add no diagnostic value. The correct recovery action is to fix the caller, not to branch on ok.

Dynamic-schema table helpers throw at bind time when required configuration is absent, because DuckDB requires the result schema to be fixed at bind time and a NULL schema cannot be returned.

Scalar frame accessors (ducknng_frame_error_text, ducknng_frame_name, ducknng_frame_type, and similar) return NULL for absent or invalid frames rather than throwing. This is the correct SQL behavior for projection and filtering use cases.

Frame envelope errors carry two distinct values. The high byte of the control word carries a ducknng protocol status such as INVALID, NOT_FOUND, or SQL_ERROR; the counted error field carries human-readable UTF-8 detail. This protocol status is not an NNG transport error. Wire-level frames do not carry nng_error as an envelope field; the numeric NNG transport code lives in the collect row that delivered the frame, not inside the frame payload itself.