Skip to main content
Version: Development

Bounded NATS JetStream triggers

latent_nats::triggers::NatsTriggers implements the operator-installed nats-jetstream-pull-v1 profile. It delivers external events through the existing publication catalog, admission controller, scheduler and fresh activation stores. It uses the same confined TLS transport and rotating opaque credentials as the publisher. It adds no guest import and no transactional outbox.

Authority and binding​

Each TriggerBinding fixes an ID, tenant, trusted principal subject, service, contract, function, optional route, stream, durable consumer, exact filter subject and reduced root budget. The host derives a Trigger principal, a fresh root activation ID and trace. Event bytes and headers cannot select authority, lineage, route, budget or deadline. Payloads must encode the selected function's argument array using application/vnd.latent.wit-values.v1+json. Put application-level deduplication keys in that payload when required.

Bindings have unique IDs and stream/consumer pairs. Different tenants cannot use the same physical filter subject in one configuration. Up to eight tenant clients share a configuration; every tenant needs its own NatsCredential bound to that tenant, provider ID and exact TLS destination. Broker credentials should permit only the configured consumer-info, pull and acknowledgement subjects and private reply inboxes. Provision consumers separately with operator credentials. Production trigger code cannot create streams or consumers.

TLS uses one approved IPv4 socket and independently verified server name. Non-public addresses require exact opt-in. The profile requires handshake-first TLS and supports no DNS discovery, reconnect loop, plaintext, proxy or automatic redirect. The pinned conformance broker is NATS 2.14.6; its image and transport details are recorded in the publisher profile.

Finite pull and activation ownership​

Installation retains metadata only. The embedding runs one caller-owned run(&manager, stop_receiver) future, or drives step explicitly. One loop and timer rotate tenants and then each tenant's bindings. Each step handles at most one message and one activation. There is no task, socket, retry timer, subscription or long-running activation allocated per dormant service.

Before contacting the broker, LocalActivationManager::reserve_inbound pins the route and reserves admission and the maximum input allocation. It creates no guest store or execution cell. An unavailable quota causes no pull. The provider then reserves a shared ingress request, response/framing memory and connection capacity. The request permits exactly three protocol operations: inspect consumer, pull one message, and acknowledge its terminal outcome. Socket/parser ownership remains charged through cancellation and cleanup. Empty polls drop their node reservation; they can create bounded journal history but do not prepare or invoke a guest.

Publication eligibility is checked before networking and again before pulling. Credential currentness is checked before the pull and acknowledgement. Accepted input keeps its original admission, route generation and deadline; future pulls resolve current routes. The backend retains its final guarded start check, so a cached component does not authorize a revoked publication.

Profile settingSupported bound
Trigger bindings1–256; each name bounded, no wildcard filter
Tenant clients1–8; lazily created and shared across their bindings
Payload1–32,768 bytes
Headers / protocol linesAt most 8 KiB each; finite control-frame processing
Delivery operation timeout100–10,000 ms, one original deadline
Root wall-time budgetPositive, at most the operation timeout
Poll interval10–1,000 ms; one timer for the owner
Broker delivery count1–16
Broker acknowledgement waitAt least operation timeout + 1,000 ms, at most 60,000 ms
Negative-ack delay100–10,000 ms
Retained public configuration encodingAt most 512 KiB

Shared provider pool limits additionally bound all providers' requests, metadata, clients and connections, including tenant/provider ceilings, idle age and dial backoff. Installation charges 64 KiB plus 2 KiB per binding and six times retained root-certificate bytes. Each ingress request charges 64 KiB plus twice the payload ceiling; physical TLS connections have a separate 256 KiB reservation. These are accounting bounds, not whole-process RSS measurements.

Broker profile and terminal outcomes​

The consumer-info reply must identify the exact stream, named durable consumer and filter. Required settings are explicit acknowledgement, instant replay, deliver-all, max_waiting = max_ack_pending = max_batch = 1, max_expires = 100000000 ns and max_bytes = payload_ceiling + 9216. ack_wait and max_deliver must match the operator configuration. Push delivery, headers-only delivery, flow control, multiple filters and consumer backoff arrays are rejected.

Each pull requests batch one with no-wait and a 100 ms expiry. Known empty, expired or capacity-limited replies stop that pull. Oversized, malformed or foreign frames close the connection and are observable errors; they never trigger an unbounded drain or local replay queue. Operators must ensure stream messages fit the configured payload ceiling. Messages the broker cannot deliver within its byte limit require operator correction.

Acknowledgement subjects are parsed using a closed grammar and checked against the configured stream/consumer and positive sequence/count fields. Classic and no-domain v2 subjects are supported; arbitrary reply subjects are rejected.

Activation outcomeBroker action
Success+ACK
Declared application failure+TERM
Invalid arguments, incompatible contract, authentication or permission rejection after delivery+TERM
Timeout, cancellation or other platform failureDelayed -NAK while attempts remain
Retryable failure at the configured last delivery+TERM, reported as exhausted
Operator shutdown with pending workClose/reclaim locally, leave delivery unacknowledged

ACK, NAK and TERM all require a broker acknowledgement. A possibly written command without its verified receipt is uncertain. This applies both when the broker committed the command and when the command never reached it. There is no automatic acknowledgement replay. Unacknowledged messages can redeliver and execute the guest again. Successful acknowledgement is unrelated to an LSF transaction or downstream external-effect durability. See the NATS pull-consumer contract and acknowledgement protocol.

Configuration, recovery and operations​

Trusted Rust composition calls NatsTriggers::install(pools, logical_id, epoch, expected_epoch, config, credentials) and retains its TriggerMonitor. Persist only validated TriggerConfig::to_json() bytes and the separately protected credential configuration. Restore with TriggerConfig::from_json; local offsets or delivery receipts are not persisted. The broker owns durable consumer position. Configuration replacement uses the provider registry's checked epoch transition.

TriggerMonitor::snapshot reports bounded aggregate counts. last_step() retains one fixed-size terminal/error report with configuration epoch, binding index, stream sequence, delivery count, pinned route generation and acknowledgement state. It contains no payload, broker diagnostic string or secret. An empty poll does not erase it. A sequence of failures requires external observation; this slot is not a durable event log. Consumer delivery exhaustion remains observable in the broker even when a node cannot receive the final attempt.

Signal the watch channel and await run to finish cleanup. Explicit step users call close_idle() when stopping; shared pool shutdown owns final reclamation. Dropping an activation waiter is not a substitute for driving backend cleanup. The standalone node has no NATS trigger installation field. Supply this configuration, protected credentials and the owned run future through the Rust embedding; declaring a trigger alone cannot start an external broker connection.

cargo test --locked -p latent-nats --lib
cargo test --locked -p latent-node --test activation_lifecycle
python3 tools/run_nats_event_tests.py --suite nats_triggers

The owned runner uses one pinned TLS broker with 256 MiB memory, one CPU, finite streams and bounded logs, and removes its files and container. CI reuses the built test harness. Actual broker/node/Wasm tests cover 256 idle bindings, overload, two-tenant fairness, connection reuse, route replacement, scoped revocation, declared failure, invalid payloads, finite poison retries, both lost-ack cases, restart redelivery, and pending/executing shutdown cleanup. No large benchmark campaign or retained broker data is required.