Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Rullst Core ⚙️

rullst-core contains Rullst’s runtime primitives, Axum-compatible routing, state management, health probes, process telemetry, queues, and configuration helpers.

Core is runtime-only by default. Enable orm for ORM bootstrap/artisan and database-backed feature flags, and queue-sqlite for the SQLite queue driver. orm adds no SQLx driver: pair it with drivers-all or one strict-* backend. The umbrella rullst crate enables none of them by default (up to 13.0.0-alpha.2 its defaults were orm, drivers-all and queue-sqlite), and domain crates opt in only when they actually use them.

Queue monitoring capabilities are driver-specific. The trait defaults for listing all jobs, retrying failures and purging failures return QueueError::Unsupported; they never fabricate an empty snapshot or successful mutation. purge_failed_jobs is the canonical facade method. The deprecated purge_completed_jobs name is retained only as a source-compatibility alias for the historical operation, which actually removed failed jobs. The Redis driver implements all three: list_all_jobs returns at most 1,000 rows (failed jobs and dead letters newest first, then processing, pending and scheduled jobs, read without one atomic snapshot; created_at is empty because Redis does not record it), retry_failed_job moves a failed job to the tail of the pending list while keeping its attempt counter, and purge_failed_jobs deletes every failed job and dead letter.

The v13 list_job_previews(limit, max_field_bytes) (on QueueDriver and the Queue facade) lists the same records as QueuedJobPreviews whose payload and error hold at most max_field_bytes bytes, cut on a UTF-8 boundary, with payload_truncated/error_truncated flags. SQLite cuts the values in SQL and Redis in one atomic Lua script that decodes at most limit envelopes, so complete payloads never leave the store. Custom drivers inherit a default that projects their list_all_jobs (and therefore still loads complete records).

Cache diagnostics are driver-specific too. Cache::inspect(limit) accepts 1–200 and returns sorted logical-key, UTF-8 value-length and remaining-TTL metadata for Memory and Redis without the value. Exact keys are still application data and CacheEntryMetadata::logical_key() belongs only inside an authorized diagnostic boundary; its Debug output redacts the key. Custom drivers return CacheError::InspectionUnsupported unless they implement the bounded method. The live Redis CI/release contract checks metadata, TTL and non-disclosure; it does not prove cluster/failover or operator authorization. The Redis cache and queue drivers each keep one lazily opened multiplexed connection for all operations and replace it after a connection-level failure; each queue command waits up to 10 seconds for its reply. The memory cache stores a TTL too large for the monotonic clock (such as u64::MAX) as non-expiring instead of panicking. A read that finds an expired entry removes the key only while it still holds that expired value, so a concurrent refill is kept.

SQLite deletes successful jobs by default. Applications that need a real Studio/operations history can opt in with Queue::sqlite_with_completed_history(database_url, retained_jobs). The validated limit is 1–100,000 records; status transition and pruning commit in one transaction, and purge_completed_history removes the retained successes. Rows still contain the original payload, so access control and retention policy belong to the host. Redis/custom drivers do not inherit this policy implicitly. The Redis driver bounds its own failure state instead: failed jobs (with payloads) and dead letters are each retained up to 10,000 entries by default, the oldest evicted atomically, and RedisDriver::try_with_failure_retention(failed_jobs, dead_letters) accepts 1–100,000 for each. Failures recorded before that bound existed are not indexed, so they are not listed and never evicted automatically.

Queue::dispatch and dispatch_at reject an empty job name or one longer than 256 bytes with QueueError::InvalidConfiguration. Queue::dispatch_at persists a due timestamp for at most 366 days through the built-in SQLite and Redis drivers. SQLite filters claims by local wall-clock milliseconds and claims in due-time order, so a due scheduled or handed-back job does not wait behind later immediate jobs; Redis atomically promotes bounded batches using Redis server time. Neither backend claims a scheduled job early. Execution starts on the first worker poll after it becomes due and retains the queue’s at-least-once semantics. Worker drives each pop to completion instead of racing it against completions or shutdown, because both built-in claims commit before the future resolves. A job claimed after graceful shutdown was requested is requeued. When a handler finishes while its timeout or a graceful shutdown is being processed, the worker records the handler’s own result: only a handler that was actually cancelled is failed as timed out or requeued, so a success is never reported as a timeout or run again. Worker::run rejects a zero max_concurrency, poll_interval or job_timeout and a stalled_after that does not exceed job_timeout with QueueError::InvalidConfiguration. Stalled-lease recovery runs when a worker starts and then every min(stalled_after, 60 s), and it is queue-wide: it returns every processing lease in the shared SQLite table or Redis namespace that is older than the recovering worker’s stalled_after, including leases of other workers. Since v13, workers claim through QueueDriver::pop_with_lease with their own stalled_after; SQLite and Redis store that lease with the claim and recovery honours it whatever age the recovering worker uses, so a pool with a short stalled_after no longer requeues a slower pool’s running job. Claims without a lease (older workers, custom drivers or direct pop calls) still stall after the recovering worker’s age: while any exist, every worker that shares a queue must use a stalled_after longer than the longest job_timeout of any of them. A job that crashes, aborts or hangs its worker would otherwise be recovered and claimed forever, so the SQLite and Redis drivers count stalled leases per job and fail the job, instead of requeuing it, when its fifth lease stalls. The failure is listed and retryable like any other failed job, and retry_failed_job restarts the count. Since v13, SqliteDriver::try_with_max_stalled_leases and RedisDriver::try_with_max_stalled_leases change the ceiling (1–1,000, default DEFAULT_MAX_STALLED_LEASES = 5). Worker transitions are fenced by the claim’s attempt number. The SQLite and Redis drivers complete, fail or requeue a job only while it is still processing under the attempt that pop returned, so a worker whose lease was recovered and claimed again receives a StateTransition error instead of finishing, failing or deleting the newer claim. The new QueueDriver::mark_complete_attempt, mark_failed_attempt and requeue_attempt methods default to the unfenced methods, so custom drivers keep their behaviour until they override them. The stale handler may still have run its side effects (delivery stays at-least-once). SQLite and Redis retry_failed_job keep the attempt counter, so the fence also holds across a manual retry. A worker that claims a job whose name it has no handler for hands the claim back instead of failing it. SQLite and Redis make the job claimable again after five seconds, behind jobs that are already due, so a worker that registered the name (for example a newer version during a rolling deploy) can run it; the delay keeps the claiming worker out of a hot loop, and it still reports HandlerNotFound each time. From the 720th claim attempt (at least an hour of hand-backs) the job is failed with HandlerNotFound instead, so a job that no running worker can handle becomes visible and purgeable; since a retry keeps the attempt counter, deploy a handler before retrying it. Custom drivers that do not implement QueueDriver::requeue_attempt_after keep the previous behaviour and fail the job. ValidatedForm/ValidatedJson failures keep REST status codes (400, 413, 415 or 422 JSON) for other clients, but an HTMX request receives its escaped HTML fragment with 200 OK and an X-Rullst-Validation-Status header carrying that status, because htmx swaps only successful responses by default. Scheduler::task takes a POSIX five-field expression (minute hour day-of-month month day-of-week) evaluated in UTC. Day-of-week accepts 0-7 (0 and 7 are Sunday, 1 is Monday) and names, so 0 9 * * 1-5 runs Monday to Friday. When both day fields are restricted, a day matching either one runs the task (0 0 1 * 1 is the 1st plus every Monday); a field starting with * keeps the intersection. Earlier releases passed the fields to the cron crate unchanged, where 1 was Sunday and 0 was rejected. Messaging’s durable recurring publications keep their documented cron-crate projection. See time zones for scheduled tasks to match the fields against a local clock instead of UTC. WorkerHandle and SchedulerHandle buffer at most 256 undrained errors. Once the buffer is full, newer errors are dropped, counted by dropped_errors() and emitted as tracing warnings, so a handle that is kept alive but never drained does not grow memory. Drain next_error (for example from a supervising task) to observe every failure. A scheduler attached with Server::schedule is drained by the server: each task failure is logged as a tracing error on the rullst::scheduler target when reported, and a past task failure no longer turns a clean shutdown into Err(ServerError::Scheduler); only a failed scheduler loop does. Custom drivers return QueueError::Unsupported for future timestamps unless they explicitly implement durable scheduling.

ApplicationLifecycle supplies an opt-in process-local startup/readiness/drain contract. Up to 32 immutable validated component labels can gate readiness and application admission; /ready publishes only aggregate counts, not labels or dependency errors. Server::with_lifecycle marks the phase ready after binding, begins draining before Axum’s graceful wait, and marks it stopped on completion or startup failure. run_with_shutdown accepts a caller-owned trigger for embedded supervisors and deterministic tests. The lifecycle does not run dependency probes, coordinate replicas, authorize users, or guarantee load balancer propagation.

Since v13, the drain retains admission through the response body, including streaming/trailers, error and cancellation. It does not track upgraded connections or prove client receipt. The two-replica deployment contract exercises this boundary through a real proxy; its hosted workspace and installed-archive checks passed in PR #224, and the final release campaign remains separate.

✨ Core Features & Subsystems

  • Axum-compatible routing: rullst::Router wraps and converts to/from axum::Router; application latency depends on handlers, middleware, build profile, and deployment.
  • Typed server functions: concrete async #[server_function] items share owned Serde arguments/results through the versioned rullst.client v1 envelope. The generated native router and Wasm caller enforce same-origin paths, bounded bodies, correlation and redacted errors; hosts still own identity, tenant, authorization, idempotency and rate-limit policy.
  • Rullst Radar (rullst::radar): Collects process RSS/CPU where an OS probe is supported, Tokio task/yield observations when a runtime is available, and process uptime. Unsupported probes return None: RSS and CPU have probes on Linux and Windows only, so macOS reports neither. On Linux, RSS comes from VmRSS in /proc/self/status (correct on 16/64 KiB page kernels), and CPU percent is process CPU time over wall time: the host-wide /proc/stat delta is scaled by its host CPU count, not by the cgroup-limited available_parallelism, so a container saturating a 2-CPU quota reports about 200%.
  • Prometheus /metrics Exporter: Text-format metrics served at GET /metrics; formatting and collection have bounded runtime cost.
  • Kubernetes probe routes (rullst::health): the simple health_router reports process availability and uptime. Server records the health and Radar uptime origin when it starts, unless init_health_boot_time or init_radar was called earlier; without a Server, call them yourself. The opt-in health_router_with_lifecycle returns readiness from the same bounded state that gates Server request admission; the application still performs and times out its own dependency checks. The version field of both payloads is the Rullst framework version. To report your application’s version too, use health_router_with_version(env!("CARGO_PKG_VERSION")) or health_router_with_lifecycle_and_version(lifecycle, env!("CARGO_PKG_VERSION")) (v13), which add it as app_version; any string works, such as a commit hash read at startup.
  • Interactive Scalar API Docs (rullst::scalar): OpenAPI documentation UI mounted at /docs, with a pinned CDN asset and a status-only fallback. A missing or malformed openapi.json returns 503.
  • Typed framework errors: startup, queues, validation, scheduling, storage, and other subsystems expose their own typed errors. Applications may compose those into an application-owned AppError; Core does not define one global application error type.
  • Redacted configuration Debug: DatabaseConfig (and therefore RullstConfig) prints only the database URL scheme, such as postgres://<redacted>; db::ReplicationConfig redacts auth_token and prints only the sync_url scheme. Fields stay public and unchanged.
  • Durable scheduled queues: SQLite and Redis persist bounded due timestamps; the live Redis CI contract proves that an immediate job remains claimable while a future job stays unavailable.
  • Opt-in completed-job monitoring: SQLite can retain and atomically prune a configured number of successful jobs; the privacy-safe default remains immediate deletion.
  • Bounded token-bucket rate limiter: RateLimiter keys IPv4 peers per address and IPv6 peers per /64 by default. It tracks at most 100,000 keys, drops fully refilled buckets and evicts the least recently used ones beyond that cap; a key longer than 128 bytes (for example from a custom extractor returning a token header) is stored as its SHA-256 digest, so the map stays bounded in bytes too. State is process-local, not a distributed limit. When attached to Server, the limiter and the Traffic Shield let exact GET/HEAD /health and /ready probes through, so load shedding or an exhausted bucket cannot fail a liveness probe. In a debug Development server with cargo rullst dev reloading, its /_rullst/dev-generation poll, /_rullst/dev-reload.js and the /_rullst/dev-telemetry poll of cargo rullst dash also bypass both and are not access-logged. In staging and production, successful /health and /ready probes are not access-logged either (failing ones are); RULLST_LOG_HEALTH_PROBES=true prints them again. Without RUST_LOG, the default tracing filter is info,sqlx=warn; see the telemetry guide.
  • Development dashboard telemetry (v13): under the same conditions Server serves GET /_rullst/dev-telemetry to loopback clients only, over HTTP/1.1 or newer and without proxy forwarding headers: bounded request counters and recent requests (method, path without query string, status, duration), ORM operation counts and slow operations from rullst.orm.query spans, and the pending count of a queue passed to Server::with_dev_queue. Bodies, headers, cookies, query strings, SQL and bindings are never recorded. See the telemetry guide.
  • Production monitoring endpoint (v13, 13.0.0-alpha.3): Server::with_monitor(rullst_core::monitor::Monitor::new()) serves GET /_rullst/monitor/v1 once RULLST_MONITOR_TOKENS (or Monitor::with_token, or an application MonitorAuthorizer) provides a credential; without one nothing is mounted. It reports request rate, latency and status classes per route pattern, registered queues, N+1 fingerprints and the radar, never request data or configuration, and requires TLS evidence in staging and production. See the production monitoring guide.
  • Trusted-proxy client resolution (v13): Server::trusted_proxies mounts security::TrustedProxyLayer outside every other framework layer. Only a socket peer inside the listed networks may report the client through X-Forwarded-For or RFC 7239 Forwarded; the resolved address replaces ConnectInfo, so existing rate limiters and lockouts use it unchanged. See Running behind a reverse proxy.
  • Bounded database flag cache: DbFeatureDriver caches a found flag, a flag without a row and a failed or timed-out lookup (missing table, unavailable database) for its TTL, so an undefined flag does not query the database on every evaluation. A failed refresh keeps serving the last value read; one lookup waits at most two seconds and each driver caches at most 4,096 flag names.
  • Feature flag buckets: percentage rollouts and A/B variants in the Env, TOML, Memory and DB drivers use calculate_hash_bucket, a versioned SHA-256 hash over a domain tag, the length-prefixed flag and the identifier. It gives every toolchain, platform and replica the same assignment. Earlier releases used std’s unspecified DefaultHasher, so upgrading reassigns users to buckets once; percentages and variant weights are kept. TomlFeatureDriver::reload parses into a new map and swaps it in at once, so concurrent evaluations never see a flag as unset mid-reload. It reads [features] with a TOML parser, so quoted flag names ("checkout.v2"), # inside strings and any valid header spelling work, and dotted keys or [features.<group>] tables become dotted flag names; a file that is not valid TOML falls back to the earlier line reader. When an A/B split’s weights sum to less than 100, an identifier outside them gets the variant "disabled" from the driver that defines the flag; it no longer falls through to a lower-priority driver’s split. Every driver evaluates an A/B flag for an identifier to its variant name, so enabled_for is true only for identifiers assigned a variant named "enabled", and enabled (no identifier) is false for an A/B flag in every driver, including a DbFeatureDriver row without a rollout percentage; gate code on variant. The v13 FeatureManager::overrides() returns the first-priority MemoryFeatureDriver of FeatureManager::default() (and of the global feature::manager() when it uses the default pipeline), so programmatic overrides reach it.
  • Project settings (v13): config::project_setting(name) (also rullst::config::project_setting) reads an application setting from the process environment first and then ./.env, which never overrides the environment and is never loaded into it, by Server or by Artisan commands (db:*, studio); read .env values in migrations and seeders with it rather than std::env::var. It returns Ok(None) when neither defines the name and fails with ConfigError::Read/Parse for an unreadable or malformed .env; errors never contain .env content. Nexus basic_from_env and generated billing code (BILLING_*) use it. The #[doc(hidden)] server::ProjectSettings and async server::read_project_setting apply the same precedence for first-party crates such as rullst-mail, and ProjectSettings::environment applies the Server precedence for RULLST_ENV/APP_ENV/[app].env; they are not a stable extension point.
  • Bounded cache metadata: Memory and Redis expose value length and TTL for at most 200 sorted entries, never cached values. Rullst Studio renders keyed opaque identifiers and one-entry invalidation rather than exact keys or bulk flush.

🚀 Usage

Most applications can use the re-exports provided by the umbrella rullst crate instead of depending on rullst-core directly.

Mounting lifecycle-aware Health Probes & Prometheus Metrics

These optional surfaces are application-owned and must be mounted explicitly. Protect or isolate /metrics when its operational data should not be public.

use rullst_core::{
    ApplicationLifecycle, Router, Server,
    health::health_router_with_lifecycle,
    radar::radar_metrics_router,
    scalar::scalar_docs_router,
};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let lifecycle = ApplicationLifecycle::new();
    let app = Router::new()
        .merge_axum(health_router_with_lifecycle(lifecycle.clone()))
        .merge_axum(radar_metrics_router())   // GET /metrics (Prometheus)
        .merge_axum(scalar_docs_router("/openapi.json")); // GET /docs
    Server::new(app)
        .with_lifecycle(lifecycle)
        .run(3000)
        .await?;
    Ok(())
}

Running behind a reverse proxy

Behind a load balancer or TLS terminator every connection comes from the proxy, so limits and lockouts would treat all clients as one. List the networks your own proxies connect from, and nothing broader:

use rullst_core::{Router, Server, security::TrustedProxyConfig};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let proxies = TrustedProxyConfig::new(["10.0.0.0/8", "fd00::/8"])?
        .trust_forwarded_proto(true);
    Server::new(Router::new())
        .trusted_proxies(proxies)
        .run(3000)
        .await?;
    Ok(())
}

Or configure the same policy in Rullst.toml; a builder policy replaces it:

[security]
trusted_proxies = ["10.0.0.0/8", "fd00::/8"]
trusted_proxy_header = "x-forwarded-for" # or "forwarded" (RFC 7239)
trust_forwarded_proto = true

Forwarding headers from any other peer are ignored. The chain is read right to left, skipping trusted hops, and the first untrusted address becomes the client: ConnectInfo<SocketAddr> is replaced by that IP with port 0, and a ClientAddr extension records the client, the original socket peer and whether a proxy supplied it. A missing or malformed header from a trusted proxy keeps the proxy address and logs one tracing event without header contents. Enable trust_forwarded_proto only when your proxies overwrite X-Forwarded-Proto (or set proto=); ClientAddr::forwarded_proto then reports the scheme, and Nexus accepts an HTTPS report as its TLS evidence. Any host inside a listed network can choose the client address, so never list client-reachable ranges.

Deferrable jobs and time windows

Opt-in v13 API. Work that need not run immediately (reports, exports, re-indexing) can be dispatched with a Deferral: a deadline (run_by) and up to 24 daily TimeWindows, each with an optional fixed UTC offset (daylight-saving changes are not applied, except for the windows of Scheduler::deferrable_task_in_tz; an end earlier than the start wraps midnight). Queue::dispatch_deferred makes the job claimable at the start of the next allowed window (immediately inside an open window), never after the deadline; when no window opens before the deadline, it becomes claimable at the deadline. Without windows, any time before the deadline is allowed, so the job is due immediately.

Queue::dispatch_deferred_with takes a CarbonAwarePlanner around an application-provided CarbonIntensitySource (an async trait returning forecast slots with their unit; the source has a name). The job is placed at the start of the lowest-value slot that overlaps its windows before the deadline (ties go to the earliest slot). When the source returns an error or exceeds the planner’s timeout (2 s by default), the time-window rule is used and one warning is logged per outage. Core ships no network source; the deterministic FixedIntensitySource exists for tests and documentation. The planner only chooses a time from the values the source reports; Rullst measures nothing and makes no claim about emissions.

Placement is expressed through dispatch_at, so SQLite and Redis store a deferred job as an ordinary scheduled job: no new column or key, and rows written by 12.x keep working unchanged. Custom drivers without durable scheduling reject a future placement with QueueError::Unsupported. The returned DeferredJob carries the DeferralPlan (run_at, reason of window, intensity or deadline, the source name, and the source’s value and unit for an intensity placement), and a rullst.queue.deferral tracing span records the same fields with the job id. Deadlines are limited to 366 days, like dispatch_at, and execution still starts on the first worker poll after the job becomes due.

#![allow(unused)]
fn main() {
use rullst_core::queue::{
    CarbonAwarePlanner, Deferral, FixedIntensitySource, Queue, QueueError, TimeWindow,
};
use std::time::{Duration, SystemTime};

async fn defer_reports(queue: &Queue) -> Result<(), QueueError> {
    // Claimable between 00:00 and 06:00 at UTC-03:00, at the latest in 24 hours.
    let night = TimeWindow::daily(0, 0, 6, 0)?.with_utc_offset_minutes(-180)?;
    let deferral = Deferral::within(Duration::from_secs(24 * 3_600))?.window(night)?;
    let job = queue
        .dispatch_deferred("monthly_report", serde_json::json!({"month": "2026-09"}), &deferral)
        .await?;
    println!("{} due at {:?} ({})", job.id, job.plan.run_at, job.plan.reason.as_str());

    // Fixed example values; applications implement CarbonIntensitySource.
    let now = SystemTime::now();
    let hour = Duration::from_secs(3_600);
    let source = FixedIntensitySource::new("example-fixed", "gCO2eq/kWh")
        .slot(now, now + hour, 300.0)
        .slot(now + hour, now + 2 * hour, 120.0);
    let planner = CarbonAwarePlanner::new(source).with_source_timeout(Duration::from_secs(2));
    let reindex = Deferral::within(4 * hour)?;
    let job = queue
        .dispatch_deferred_with(&planner, "reindex", serde_json::json!({}), &reindex)
        .await?;
    println!("{:?} from {:?}", job.plan.reason, job.plan.source);
    Ok(())
}
}

Scheduler::deferrable_task(cron, ScheduleDeferral::within(max_delay)?.window(...)?, handler) applies the same window and deadline rules to each tick of a recurring task, with the tick as the start and tick + max_delay as the deadline. Ticks that pass while a deferred run waits are skipped, as for a slow run. Scheduled tasks do not consult an intensity source; a task can enqueue a job with dispatch_deferred_with instead.

Time zones for scheduled tasks

Scheduler::task matches its five fields against UTC. With the optional scheduler-tz feature (v13; rullst and rullst-core both have it), Scheduler::task_in_tz matches them against the wall clock of a named IANA time zone, so “07:00 in Brasília” is written as it is said:

use rullst::Scheduler;

let scheduler = Scheduler::new()
    // 07:00 São Paulo time, which is 10:00 UTC.
    .task_in_tz("0 7 * * *", "America/Sao_Paulo", || async {
        // send the morning digest
    })?
    // Every ten minutes; the zone does not matter for this one.
    .task("*/10 * * * *", || async {})?;

An unknown zone name, such as Brasilia, is rejected when the task is registered with SchedulerError::InvalidTimeZone { zone, message }; a bad expression still returns SchedulerError::InvalidCron. The day-of-month and day-of-week fields also follow the local date: 30 22 * * 5 in São Paulo runs on Friday evening local time, which is Saturday 01:30 UTC.

In zones with daylight saving, one rule applies:

  • A wall time that a gap skips (clocks move forward) runs at the first instant after the gap. In America/New_York, 30 2 * * * runs at 03:00 EDT on the day clocks spring forward, and */15 * * * * runs once at 03:00 for the skipped 02:00 to 02:45 and then continues at 03:15.
  • A wall time that a fold repeats (clocks move back) runs once, at its first occurrence. 30 1 * * * runs at 01:30 EDT and not again at 01:30 EST, and an hourly task runs at 01:00 EDT and next at 02:00 EST.

The zone rules come from the system database (/usr/share/zoneinfo) when it has the zone, so operating-system updates refresh them; otherwise the copy bundled with the jiff dependency is used, which keeps minimal container images without tzdata working. A task’s label in scheduler errors is the expression followed by the zone, such as 0 7 * * * [America/Sao_Paulo].

Scheduler::deferrable_task_in_tz(expression, zone, deferral, handler) (added in 13.0.0-alpha.3) is the zoned form of deferrable_task: its ticks follow task_in_tz, and its TimeWindows are wall times of the same zone, so TimeWindow::daily(22, 0, 6, 0) stays 22:00–06:00 local time across a daylight-saving change. Window boundaries follow the same rule as ticks: one that a gap skips moves to the first instant after the gap, and one that a fold repeats is its first occurrence; a window that a gap skips entirely does not open that day. max_delay is elapsed time after the tick. A window of such a task must not set with_utc_offset_minutes, which registration rejects with SchedulerError::InvalidConfiguration. deferrable_task keeps UTC ticks and fixed-offset windows.

use rullst::Scheduler;
use rullst::queue::{ScheduleDeferral, TimeWindow};
use std::time::Duration;

// The 18:00 report may wait up to 12 hours for 22:00–06:00 in São Paulo.
let night = ScheduleDeferral::within(Duration::from_secs(12 * 3_600))?
    .window(TimeWindow::daily(22, 0, 6, 0)?)?;
let scheduler = Scheduler::new().deferrable_task_in_tz(
    "0 18 * * *",
    "America/Sao_Paulo",
    night,
    || async { /* build the report */ },
)?;

Axum First-Class Escape Hatches & Tower Interoperability

rullst::Router provides bidirectional conversion with axum::Router and accepts compatible tower::Layer values:

#![allow(unused)]
fn main() {
use rullst::Router;
use axum::routing::get;
use tower_http::cors::CorsLayer;

async fn handler() -> &'static str { "ok" }

let mut router = Router::new()
    .route("/hello", get(handler))
    .fallback(|| async { (axum::http::StatusCode::NOT_FOUND, "not found") })
    .layer(CorsLayer::permissive());

// Direct conversion to raw axum::Router
let axum_app: axum::Router = router.into();

// Or wrap an existing Axum router
let rullst_app: Router = axum_app.into();
}

🛡️ Security baseline

In staging and production, Server mounts the runtime security baseline from rullst_core::security: secure headers with a per-request CSP nonce, an exact CORS allowlist, a bounded WAF, double-submit CSRF and, when enable_pii_masking is set, response PII masking. rullst-security adds opt-in layers that overlap some of these. See which security layer to use, and when before you combine them.

The secure headers include Strict-Transport-Security: max-age=31536000; includeSubDomains. [security] hsts_max_age_seconds, hsts_include_subdomains and hsts_preload change it; preload is opt-in and requires includeSubDomains and a max-age of at least one year, or the server refuses to start (see Strict-Transport-Security).

The WAF refuses requests only in staging and production; in development it logs a warn event with the rule and path of each request it would refuse. [security] waf_body_exempt_paths lists exact paths whose bodies it does not inspect, for routes that accept text such as code samples (see seeing and accepting WAF false positives).

TLS connections use aws-lc-rs as the rustls crypto provider; ring is only a primitives library (HMAC, digests, randomness, signatures). With the orm, storage-s3 or telemetry feature, Server start-up installs aws-lc-rs as the rustls process default before telemetry and the database pool build TLS clients, and so does the OTLP exporter before it builds its HTTP client. An application that installs its own provider first keeps it. See cryptography providers.

🔐 Security Audit & Reliability

Repository workflows exercise Core with unit and integration tests on every change, and with fuzz and Miri campaigns started on demand, within their declared scopes. Consult the exact workflow run and commit for evidence; these tools do not prove the absence of every panic, leak, or vulnerability.