Outbound Host Adapter v1

September 21, 2026 · View on GitHub

Status: implementation slice for issue #193; deterministic fixture evidence only. No hosted or public-network support claim follows from this module.

Audience: runtime integrators and reviewers of outbound application effects.

src/outbound_host_adapter/ joins the existing pure std policy packages and bounded HTTPS runtime at one injected host boundary. It covers typed GET/POST/PUT/PATCH/DELETE requests, structured operational export, signed webhook delivery, and a provider-neutral email envelope. The policy layer reads no environment and creates no threads or timers. NativeHttpsAdapter is the explicit physical HTTPS implementation: its host supplies TLS policy, while it disables ambient proxies, redirects, and retries. Most policy/session tests use a deterministic recording fixture and perform no network I/O. The separate native-adapter integration corpus talks only to an explicitly configured private-root loopback TLS listener; it neither uses a system root nor reaches a public endpoint.

Authority and admission

An OutboundCapability names the deployment binding, invocation, exact HTTPS origins, and hard request, response, deadline, field, and label limits. It is move-only and is consumed by one call, preventing this API from reusing a grant for automatic retry. Its public Rust constructor is a trusted embedder seam, not an unforgeable token against arbitrary Rust code: calling it asserts that deployment policy already authorized the operation. SEMAPRAX source cannot call that constructor. The capability is ordinary host configuration, not proof that an endpoint was reached. Evidence cannot be converted back into it.

Endpoint syntax, exact origin membership, deadline, request size, content type, delivery identity, idempotency key, export cardinality, and protected-field shape are checked before OutboundAdapter::send. Origins have no wildcard; userinfo, fragments, insecure schemes, controls, and noncanonical policy origins are refused. Redirects and retries are absent from the adapter contract. A trusted custom transport implementation is required to issue the one prepared request exactly once, but arbitrary Rust implementations can violate that contract. Only NativeHttpsAdapter mechanically configures no proxy, no redirect, and no retry. Exact origin admission is not IP pinning or DNS-rebinding prevention; a host that requires network-range policy must enforce it in its resolver or network boundary.

Webhook headers are boundary-owned. Callers cannot inject authorization or signature headers. WebhookSigningSecret is explicit, move-only, and redacted from Debug; its owned Rust array is zeroized on drop, with no claim about caller, allocator, transport, process-dump, or operating-system copies. HMAC-SHA-256 v2 binds a domain separator, POST, deployment binding, canonical origin, canonical request target, exact delivery ID, idempotency key, content type, and body. The delivery ID and idempotency key are also included in evidence; neither authorizes replay.

Idempotency is receiver-enforced: this adapter sends the key but cannot prove the receiver honored it. The one-shot helpers keep no durable state. The optional host-owned durable ledger described below preserves only local intent/disposition knowledge; uncertain settlement remains uncertain and never authorizes an automatic retry, even when a key was sent.

Host-owned reconciliation ledger

HostDeliveryLedger is an optional bounded reconciliation primitive for host code that owns a prepared-request dispatch boundary. It is deliberately separate from OutboundCapability and DeliveryEvidence: a trusted host chooses to invoke reconcile before its one-shot adapter action, supplying its bounded deployment/invocation/idempotency identity and the exact PreparedRequest. The ledger independently derives the existing canonical request digest and requires its idempotency component to be the one exact boundary-owned idempotency-key header. It records only the digest plus the closed local disposition. Legacy POST requests retain the v1 digest domain and byte layout. Non-POST requests use the v2 domain and bind the selected method before the remaining request fields, so extending the adapter does not silently rewrite existing email, webhook, export, or POST evidence commitments.

The existing deliver_* convenience calls remain stateless, one-shot helpers. The additive prepare_email_delivery plus EmailDeliverySession::reconcile route is the first in-tree family integration: it validates and constructs the exact request before entering the ledger, then lets only a fresh exact identity enter the injected adapter closure. A matching request gets a newly derived authority-free receipt and never calls send; a changed request conflicts before dispatch. Fresh physical work still requires the injected capability inside PreparedEmailDelivery.

This email session deliberately settles disposition-only. It discards even a bounded nonempty accepted provider response before it writes replay state and its receipt type exposes no response bytes. Therefore exact replay needs the canonical request, identity/idempotency, policy commitment, and closed disposition only; it neither pretends to replay provider payloads nor retains them in process memory. A panic after adapter entry leaves both the pre-dispatch policy commitment and the ledger's pre-reserved Uncertain { Transport } terminal record sticky, so an exact replay can reconstruct the uncertainty without adapter entry. A normal ledger refusal rolls back a newly staged policy commitment. The policy commitment domain-separates the policy ID, sorted allowed origins, and every request/response/deadline/export limit, so a host cannot silently replay through a changed policy with the same policy ID. Policy commitments and session identities are SHA-256 values; no request, raw identity, credential, or response bytes are stored. This does not silently change legacy helper behavior.

For an exact identity and request digest, a later reconcile call returns the remembered disposition and never enters its dispatch closure. A different digest under the same identity refuses before dispatch. Accepted, Rejected, and every Uncertain observation are sticky; uncertainty is specifically never auto-retried. NotDispatched is also retained for the same exact-pair non-redispatch rule, so an explicit new host attempt must use a distinct invocation identity rather than quietly reuse the first attempt. The ledger reserves a conservative Uncertain { Transport } disposition immediately before it invokes the host closure; if that closure unwinds after a physical start, normal Rust unwinding leaves the reservation intact and a caught caller still cannot retry through the ledger.

The ledger has a fixed maximum of 256 entries, rejects a full ledger before dispatch, stores no request body, endpoint, headers, response body, or raw identity components, and renders a deterministic diagnostic snapshot using only SHA-256 identity and request commitments. Those hashes are not confidential redaction: low-entropy inputs can be guessed and tested offline. Plain reconcile remains process-local. The additive reconcile_durable requires an injected LedgerCheckpointStore and acknowledges the provisional Uncertain { Transport } checkpoint before entering the physical dispatch closure, then acknowledges the terminal observation afterwards. Only CheckpointCommit::Committed permits dispatch. A known-not-committed intent is removed without dispatch; an uncertain intent stays sticky without dispatch. If terminal persistence is known failed or uncertain, the live ledger falls back to the already acknowledged provisional uncertainty. No timer, thread, or automatic retry is created.

HTTP, webhook, and email sessions add a typed durable envelope above that lower ledger. semaprax.outbound.delivery-session-checkpoint.v1 binds one closed delivery kind, the canonical lower checkpoint and its digest, and a sorted vector of session-identity, complete-policy, request, and lower-ledger identity commitments. It is bounded to 192 KiB and independently rejects a wrong kind, capacity, digest, noncanonical JSON, unknown members, or any one-to-one binding mismatch before a session can be restored. The typed store sees that complete envelope for both the provisional intent and terminal observation; it never receives raw identities, request bytes, headers, bodies, responses, signing keys, or an adapter capability.

HttpDeliverySession, WebhookDeliverySession, and EmailDeliverySession each expose typed reconcile_durable, session_checkpoint, and restore_authenticated APIs. A trusted storage host grants the restore capability against the exact outer digest and capacity. Exact restored replay never enters an adapter; an unacknowledged intent never enters an adapter; and an unacknowledged terminal state remains the already persisted uncertainty. This closes a local crash window only. It does not prove a remote receipt, receiver idempotency, durable-store freshness, or distributed exactly-once delivery.

Read-only disposition checkpoints

HostDeliveryLedger::checkpoint and the corresponding method on the HTTP, email, webhook, and structured-export sessions produce an immutable LedgerCheckpoint. Its additive semaprax.outbound.delivery-ledger-checkpoint.v1 JSON wire contains exactly schema, capacity, and entries. Each entry contains exactly an identity commitment, a request commitment, and the closed local disposition, ordered by identity commitment. The older one-way diagnostic render format is unchanged. Neither format contains endpoint, headers, signing keys, raw identity parts, request/response bytes, or provider errors. SHA-256 commitments can still expose guessable low-entropy inputs to offline comparison.

The checkpoint digest domain is semaprax.outbound.delivery-ledger-checkpoint.v1\0; SHA-256 covers that domain, the canonical wire byte length as little-endian u64, and the exact wire bytes. decode(bytes, expected_digest) bounds input to 98,304 bytes before hashing or JSON parsing, requires the independently retained digest, and validates the closed schema, 1–256 capacity, entry count no greater than capacity, lowercase SHA-256 values, unique identities, closed dispositions, and byte-exact canonical re-rendering. Accepted statuses must be 2xx; rejected statuses are the remaining 100–599 values. Duplicate fields/entries, reordered entries, alternate JSON spellings, unknown members, and invalid status/reason combinations refuse. The maximum 256-entry checkpoint fits below the wire limit.

An imported checkpoint offers read-only lookup by exact identity and prepared request. Unknown identities remain unknown; changed request bytes conflict. verify_against compares its complete state and capacity against a still-live ledger; each family session exposes verify_checkpoint for the same comparison. merge computes a bounded union of compatible observations without modifying either input. Capacity must match, and any request or disposition disagreement refuses, including replacing an uncertainty with acceptance. A panic-reserved uncertainty is preserved when exported and imported.

The checkpoint by itself remains offline observation transport. It cannot construct a capability, delivery receipt, store, or dispatch. A trusted storage host may separately grant a move-only LedgerRestoreCapability bound to the exact independently retained checkpoint digest and capacity, then call restore_authenticated. That explicit authority restores only commitment and disposition state; exact known requests replay without dispatch and conflicting requests refuse. Constructing the restore capability around attacker-selected bytes authenticates nothing: the host must authenticate provenance/freshness and protect against rollback. The module performs no filesystem I/O or fsync, does not retain raw identities or request bytes, and cannot prove remote receipt or receiver idempotency. It closes the local ACK-before-dispatch crash window for a correctly implemented store, not the distributed exactly-once problem.

High-level HTTPS requests

deliver_http and prepare_http_delivery admit an explicit HttpMethod, canonical HTTPS endpoint, stable request and idempotency identities, optional content type, bounded public headers, body, and deadline. The physical adapter receives the selected method; the canonical request digest binds that method in addition to endpoint, ordered headers, body, deadline, redirect limit, and response limit. GET bodies are refused. Redirects and transport retries remain fixed at zero for every method.

Header admission is deliberately narrow. Names must be canonical lowercase, values are bounded and control-free, duplicates refuse, and callers cannot supply the boundary-owned content-type or idempotency-key names. Credential-shaped names (authorization, proxy-authorization, cookie, set-cookie, and x-api-key) and the shared exact protected-name aliases (api-key, bearer-token, session-token, access-token, refresh-token, and deployment-signing/SMTP names) are refused from the caller-controlled value. Matching remains exact after ASCII case and dash/underscore normalization rather than guessing from arbitrary substrings. A trusted provider adapter may apply deployment-owned credentials outside the request value; this module does not read a secret store or expose credentials to source, debug output, checkpoints, or evidence.

HttpDeliverySession gives the same bounded disposition replay as email/webhook/export sessions. An exact identity and complete request, including method, replays without entering the adapter; changing only the method conflicts before dispatch. Accepted response bytes are intentionally dropped by the session, while the one-shot deliver_http result may return a bounded accepted body. Its typed durable route persists the complete policy and request binding alongside the lower intent/disposition checkpoint; panic, deadline, transport, and response-overflow uncertainty stay sticky. This does not claim remote exactly-once delivery, DNS pinning, provider authentication, or public-network support.

Email envelope

deliver_email accepts one explicit EmailRequest and consumes the same single-use capability before handing an application/vnd.semaprax.email.v1+json request to the injected adapter. The body is a canonical, bounded envelope with the sender, authored-order recipient and attachment vectors, optional Reply-To, subject, and body bytes. Duplicate recipients and attachment names are refused; the boundary never reorders caller-authored vectors because their order can be meaningful to a provider. Raw body and attachment bytes use lowercase hex in that envelope, so the host adapter receives an exact byte sequence without a text-decoding ambiguity. They are omitted from request debug output and from delivery evidence, which retains only the prepared-request digest.

Admission tightens the pure std.email policy boundary at the host seam: sender, recipients, and Reply-To require an ASCII mailbox with exactly one @, safe local atoms with no leading/trailing/consecutive dots, and dotted domain labels of ASCII alphanumeric/hyphen bytes (1 through 63 bytes, no edge hyphen). Recipients are 1 through 64. Subject is required, refuses every ASCII control, and is at most 256 bytes. Bodies are at most 8 KiB; there may be at most four attachments, each with a closed safe file name, a one-slash media type, and at most 2 KiB of bytes. The JSON writer is capped by the deployment's existing request limit as well, so escaping/encoding cannot turn admitted members into an unbounded wire body.

verify_email_envelope is the authority-free replay side: it bounds input before allocating member vectors, requires the closed schema/key/type shape, lowercase-even hex, admitted mailbox and attachment members, and byte-exact canonical JSON re-rendering. It then requires the decoded bytes to be the exact body of a prepared email request with the boundary-owned content type and delivery headers. Unknown, duplicate, reordered, malformed-hex, and over-bound encodings refuse. This verifies one request representation only; it does not recover a capability, prove provider receipt, or authorize a retry.

This is not SMTP and it neither resolves MX records nor manages an SMTP or provider credential. A deployment selects a provider endpoint and trusted adapter implementation; that adapter may apply deployment-owned authentication outside the caller-controlled request. The request value and evidence never grant that authority. Email content is intentionally delivered to the selected provider adapter, so callers must apply the pure std.email/redaction policy to their source data before constructing an email request; this Rust host boundary does not infer secret classification from arbitrary bytes.

Settlement and evidence

A response is Accepted only for 2xx. Other complete responses are Rejected. A fixture or physical adapter may report NotDispatched only when it knows the transport was never started. Deadline, response overflow, and every failure after start are Uncertain, because the remote service may have acted before the local observation failed. The boundary performs no automatic retry in any case.

DeliveryEvidence binds the schema, deployment, invocation, policy, canonical origin, complete prepared-request digest, delivery ID, idempotency key, and settlement. Its bounded canonical decoder re-derives the origin and delivery identities from the exact prepared request and checks the caller-retained settlement. This is diagnostic evidence only: it is not a capability, delivery receipt from the remote party, or permission to retry.

Operational exports encode sorted, duplicate-free labels and fields with fixed cardinality and value limits. A protected field has no plaintext variant at the boundary: it renders [REDACTED] plus an optional SHA-256 commitment. The boundary also normalizes ASCII case and -/_ spelling for the closed credential-name inventory shared with std.log.redact; a protected name cannot carry a public field or label value. Matching is exact, not substring-based, so an ordinary name such as password_hash is not silently classified. This is a bounded name policy, not content-based secret discovery. The export_after_primary result keeps the primary application outcome separate from export settlement, so exporter failure cannot replace it.

Typed metrics and completed spans

prepare_metric_export adds a closed metric wire rather than asking a host to interpret an arbitrary event. Each observation carries a stable metric ID, a separate observation ID, sorted duplicate-free labels, and exactly one of counter_increment, gauge, or histogram_observation. Counter increments must be nonzero. Values are integers, avoiding non-canonical NaN and infinity spellings. Label count uses the deployment policy, each label value uses the fixed export-value bound, and the session ledger supplies an additional hard per-process observation ceiling. Protected names are refused before adapter entry; no global metric registry or unbounded cardinality set is allocated.

prepare_span_export carries one completed span with canonical lowercase nonzero trace and span IDs, an optional distinct parent span ID, a bounded name, elapsed microseconds, a closed unset/ok/error status, and sorted duplicate-free attributes. Public protected-name attributes refuse, while an explicit ProtectedExportValue can only become [REDACTED] and an optional commitment. IDs are caller inputs: this layer deliberately does not read ambient randomness or a clock. A trusted host that generates IDs must acquire its own declared entropy capability before constructing a span.

TraceContext provides that explicit construction and propagation path. A trusted host injects TraceEntropyCapability, whose successful fill contract requires uniformly selected cryptographically secure random or pseudorandom bytes; providers unable to guarantee that fail instead of setting the W3C random-ID flag falsely. Fresh contexts request exactly one 16-byte trace ID and one 8-byte span ID, while inbound and child contexts request one new 8-byte span ID. The boundary accepts only the fixed W3C traceparent version 00 shape with lowercase, nonzero identifiers and only the sampled and random-trace-id flags. It preserves admitted trace flags, records the received span as the parent, and emits the new local span in the outbound header. Entropy failure, zero output, identifier collision, unknown versions, reserved flags, and malformed or extended inputs fail closed before any export. SpanExport::from_trace_context binds the same typed context into the completed-span wire; parsing an inbound header never grants outbound authority.

TraceState is the corresponding closed carrier for one W3C tracestate field. It admits at most 512 serialized bytes, 32 unique members, and 256 bytes per key or opaque value. Keys use the W3C lowercase identifier alphabet; values are printable ASCII without , or =, and their opaque bytes remain unchanged. W3C-permitted empty/OWS-only members are skipped; duplicate keys, controls, invalid key/value shapes, and any over-bound input refuse direct TraceState admission. Only optional whitespace surrounding members is removed, yielding one canonical comma-delimited output field. TraceContext::from_headers parses traceparent before attempting its companion state, so an invalid parent discards tracestate without touching the entropy capability, while invalid state is independently discarded and does not invalidate a usable parent. This deliberately bounded v1 carrier does not accept or reconstruct repeated raw HTTP fields.

TracedHttpRequest binds one TraceContext and optional admitted TraceState to the ordinary bounded HTTP request path. It adds canonical traceparent and, when supplied, canonical tracestate generated from typed values after ordinary request validation. Both headers count toward the global header limit and enter the prepared-request digest and therefore HTTP replay identity. A caller cannot inject either through HttpHeader; raw repeated or unvalidated vendor state has no path to an adapter. The trace context is correlation data, not a permit: constructing it creates no socket, and dispatch still requires the deployment-owned OutboundCapability plus an injected adapter.

Metric observations and spans have separate domain-separated idempotency keys and media types. A span's replay identity binds both its trace ID and its trace-scoped span ID, while the provider-facing header retains the span ID. Their MetricExportSession and SpanExportSession wrappers reuse the same disposition-only ledger semantics: exact replay cannot enter an adapter, payload drift conflicts, accepted response bytes are dropped, and post-start failure remains uncertain. These are fixture-testable host boundaries, not a hosted telemetry provider, durable exporter, global aggregator, sampling engine, or claim of delivery.

Their authority-free verify_metric_export and verify_span_export paths independently decode a closed v1 schema, enforce global bounds and canonical ordering/JSON bytes, and bind the payload identity and media type to an exact prepared request. Unknown, duplicate, reordered, malformed, over-bound, and request-mismatched inputs refuse; successful verification cannot reconstruct a capability or enter an adapter.

Focused evidence

Once the module is wired from src/lib.rs, the focused selector is:

cargo test --locked -p semaprax --lib outbound_host_adapter::tests::

The corpus covers exact signing and replay binding, pre-dispatch refusal, origin/userinfo/fragment checks, deadline/body maximum-plus-one, header and identity injection, response maximum-plus-one, sticky uncertainty, redaction, cardinality and duplicate-name refusal, and preservation of primary failure. The email cases add canonical replay, header and address injection, recipient and attachment cardinality, member maximum-plus-one, exact boundary admission, noncanonical/unknown/duplicate/malformed-hex envelope hostility, and after-start uncertainty. The integrated email session cases add a nonempty accepted provider response that is intentionally not retained, exact replay with zero adapter calls, changed-request and policy-commitment refusal before dispatch, and an unwinding adapter attempt whose reserved uncertainty cannot be retried. The reconciliation cases add bounded-capacity refusal, duplicate exact replay without redispatch, changed-payload conflict refusal, idempotency/header mismatch refusal, sticky uncertainty after an unwinding dispatch closure, and deterministic commitment state. It performs no live network operation.

The HTTP cases execute all five typed methods through the injected adapter, cover endpoint/body/header/content-type maxima and credential-name refusal, redacted debug surfaces, exact no-redispatch reconciliation, method-only conflict, bounded response settlement, sticky panic uncertainty, and read-only checkpoint verification. They exercise fixtures only, not a public endpoint. The typed trace HTTP selector is outbound_host_adapter::trace_http::tests::. It checks canonical typed traceparent/tracestate dispatch, raw reserved header refusal, shared-header accounting, and changed context or vendor state idempotency conflicts before redispatch. The trace-context selector covers bounded hostile tracestate admission and paired-header ordering. These use recording adapters only and do not claim a hosted collector or remote propagation.

outbound_host_adapter::native_tests:: independently exercises the repository NativeHttpsAdapter against a private-root loopback TLS peer. It proves a successful exact PUT request/body/target projection, a redirect returned as the original 302 response, certificate rejection as a post-start transport uncertainty, the declared-content-length response maximum, and a malformed prepared request rejected before opening a socket. This is local physical loopback evidence only: it does not prove DNS pinning, a system trust-store policy, public-PKI interoperability, hosted execution, or remote delivery.

The same local selector also carries one HttpDeliverySession through a real TLS POST: its host-owned store retains each exact committed typed checkpoint, and restore receives only the final committed bytes, digest, and capacity. A one-byte checkpoint mutation refuses before restoration. The exact restored request replays through a real NativeHttpsAdapter while a replacement loopback listener proves no second TCP connection occurred. A separately valid request under a changed complete host policy, and a separately valid changed event body under the same identity, are both refused before that listener can observe a connection. This is a host-owned loopback fixture; it does not claim that the task-service fixture configuration (which selects only fixture adapters and no capabilities) authorizes a physical send.

The sibling native_delivery_tests corpus exercises the two remaining typed delivery families over the same kind of private-root loopback TLS boundary. One explicit host policy/capability sends a signed webhook and one provider-neutral email envelope to their exact paths; the peer checks the boundary-owned media type, delivery and idempotency headers, an independently calculated exact webhook HMAC, and a body marker without observing a secret. The email peer also rejects credential-style headers. Each typed store retains the provisional and terminal checkpoints. A tampered stored wire refuses before restoration; exact restored replay, changed payload, changed signing secret, and changed complete policy produce no second TCP connection. An untrusted private peer settles each already-started request as transport uncertainty, never an automatic retry. These are host-owned local fixtures only: they do not prove a provider accepted a delivery, receiver idempotency, SMTP/MX support, provider authentication, public-network behavior, durable-storage support, or authority for the credential-free service fixture.

The selector also proves the service host-request bridge without treating a configuration declaration as authority. A canonical host-mode service configuration renders the bounded request-v1 handoff; a separate closed decoder retains its OTLP collector origin and exact four required capability names. The test then gives the host—not the request—a fresh policy and capability. A target absent from that policy, or insecure configuration drift, refuses before adapter work; only the separately granted policy matching the decoded private-root loopback origin reaches the fixed /v1/metrics route.

The sibling native_telemetry_tests corpus completes that local vertical for every fixed collector signal: separate matching host grants carry the decoded origin to /v1/metrics, /v1/spans, and /v1/events. The private TLS peer checks each route, media type, typed identity header, and canonical body marker. After those three accepted requests settle, exact typed replay returns from the corresponding session after the listener has exited, proving it does not start a fourth physical connection. This is still only private-root loopback evidence: the request handoff is intent rather than authority, the credential-free service fixture grants no physical adapter, and it is not a hosted collector, provider-interoperability, durable-storage, public-network, or DNS-rebinding claim.

This is local physical loopback evidence for the host boundary, not an authorization for the credential-free task-service fixture or a hosted collector claim.

The checkpoint selector is outbound_host_adapter::ledger::checkpoint::tests::. Its seven cases cover deterministic all-disposition round trips, the empty-state known-answer digest, commitment/live-state drift, exact lookup, monotonic union/conflict refusal, panic-reserved uncertainty, full-capacity admission and byte/inventory limits, hostile schemas/digests/statuses/noncanonical wires, and exports/imports from the adapter sessions without extra adapter calls.

The durable-ledger selector is outbound_host_adapter::ledger::durable::tests::. Its four cases prove the intent ACK precedes physical adapter entry, known-failed and ambiguous intent commits do not dispatch, a lost terminal ACK remains sticky uncertainty, and an exact host-authorized restart replays without constructing another physical attempt. This injects an in-memory store fixture only; it is not filesystem, database, hosted collector, or remote-delivery evidence.

The typed telemetry selectors are outbound_host_adapter::metrics::tests:: and outbound_host_adapter::spans::tests::. They cover canonical label/attribute ordering, all three metric kinds, exact no-dispatch replay, response-byte discard, protected-name and malformed-context refusal, redaction, sticky transport uncertainty, per-record maxima, and session maximum-plus-one. These tests inject a recording adapter and perform no network I/O.