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 Specification 📄

“The Single Source of Truth (SST) for Framework Architecture & Conventions”

This document is the Single Source of Truth (SST) for the Rullst Framework. It specifies the exact conventions, API structures, naming rules, directory standards, and subsystem maturity lifecycles across all monorepo crates.

Important

AI & Human Alignment Directive: Whenever updating, refactoring, or generating code/documentation for Rullst, always refer to this specification as the baseline. Every capability in the framework is strictly tagged with its implementation lifecycle status:

  • 🟢 [Implemented / Bounded]: A defined implementation exists with automated tests for the stated scope. This is not a deployment, provider-homologation, or certification claim.
  • 🟠 [Partial]: Useful foundations exist, but a named interoperability, architecture, or conformance boundary is still incomplete.
  • 🟡 [Offline Test Mock]: Deterministic offline sandbox fixtures for local development and offline CI without external API dependencies.
  • 🔵 [Roadmap / Under Development]: Planned architecture, traits or domain models; consult each entry for what exists and what remains unimplemented.

📂 1. Directory Structure Conventions

A standard Rullst application scaffold strictly adheres to this folder hierarchy:

my-app/
├── src/
│   ├── controllers/      # Route controllers (async request handlers)
│   │   └── mod.rs
│   ├── models/           # Active Record & Repository Models (rullst-orm entities)
│   │   └── mod.rs
│   ├── pages/            # Shared HTML views, templates, and layouts
│   │   └── mod.rs
│   ├── middlewares/      # Custom application middleware layers
│   │   └── mod.rs
│   ├── migrations/       # Schema migrations (database-backed projects)
│   │   └── mod.rs
│   └── main.rs           # Application entrypoint, server bootstrap & central routing
├── static/               # Same-origin static assets (HTML starters)
├── Cargo.toml            # Project cargo dependencies
└── Rullst.toml           # Framework configuration (database, environment, secrets)

The SaaS starter creates every folder above. The Blank and API starters begin with src/main.rs and, for a database profile, src/migrations/; the make:* generators create the other folders and their mod.rs on first use. A --no-database Blank project has no Rullst.toml. Every starter also writes .env.example, AGENTS.md, .llms.txt and .rullst/context-map.json (section 11.1).


🛠️ 2. Naming Conventions

To guarantee consistency, both humans and AI coders must adhere to the following naming normalization rules:

  • File Names: Standard Rust snake_case (e.g. users_controller.rs, post_model.rs, billing_service.rs).
  • Struct / Model / Trait Names: Standard PascalCase (e.g. UsersController, PostModel, PaymentProvider).
  • URL Paths: Lowercase kebab-case (e.g. /users, /user-profiles, /billing/webhooks).
  • Database Identifiers: Snake case (e.g. user_id, created_at, billing_accounts).

Optional browser graphics example (v13 candidate)

examples/webgpu is a non-publishable teaching application served by Core’s production security baseline. It demonstrates two-source wave interference with browser-native WebGPU and a lower-resolution Canvas fallback. No GPU dependency or default feature is added to the framework, and no GPU work runs on the server. The example starts paused, bounds resolution and frame submission, handles GPU startup failure/device loss, and explicitly disposes resources on removal or navigation. It uses same-origin assets without a bundler, external service or capture permission. Client results are illustrative, never authorization or grading evidence. Automated Chromium/software-GPU checks are bounded example acceptance, not physical-device, browser-universal or performance evidence.


⚡ 3. Framework Crates & Capability Matrix

The v13 maintenance scope decision governs future investment and proposed extension boundaries. It does not describe an already completed package split or change current APIs, defaults, support obligations or publication inventory. The implemented capability matrix below remains the source of truth until individual migrations are reviewed and validated.

Each crate also has a v13 maturity tier: Core (rullst, rullst-core, rullst-macros, rullst-orm, rullst-orm-macros, rullst-security, cargo-rullst), Extension (rullst-auth, rullst-connect, rullst-mail, rullst-capital, rullst-ai, rullst-nexus, rullst-studio, rullst-messaging) or Experimental (rullst-iot, rullst-privacy, rullst-supervision, rullst-media, rullst-labs). The tier sets the 13.x stability and validation promise; the matrix below records what each crate implements.

CrateResponsibilitiesStatus & Capabilities
rullst-coreKernel HTTP runtime, routes!, Server bootstrap, HTML engine, async task queues, WebSockets, circular telemetry buffers, storage facade, and the default baseline CSRF/WAF/header/PII stack.🟢 [Implemented / Bounded]: Routing, server lifecycle, html! engine, graceful shutdown, backpressure guard, queues, and local storage with path-traversal protection. The process-local RateLimiter keys IPv6 peers per /64 and tracks at most 100,000 buckets, dropping refilled ones and evicting the least recently used beyond that cap. ApplicationLifecycle adds an opt-in process-local monotonic startup/ready/draining/stopped state, at most 32 immutable component readiness bits, secret-minimized /ready, fail-closed admission and a bounded drain wait. Server marks ready after binding, begins drain before Axum’s graceful wait, and accepts an explicit supervisor shutdown future; deterministic tests cover startup failure, in-flight completion, rejection after drain and lock poisoning. It does not run dependency checks, coordinate replicas/load balancers or authorize domain requests. SQLite and Redis persist dispatch_at timestamps for at most 366 days and never claim them early; Redis promotion uses server time and a digest-pinned live CI/release contract. The Redis cache and queue drivers share one lazily opened multiplexed connection per driver and reconnect after a connection-level failure instead of opening a connection per operation. Execution starts on the first later worker poll and is at-least-once. The worker never cancels an in-flight claim; graceful shutdown requeues a job claimed after the request. Timeout and shutdown record a handler’s own result when it finishes before cancellation takes effect, so only a cancelled handler is failed as timed out or requeued. Stalled-lease recovery runs at worker start and then every min(stalled_after, 60 s) over every processing lease of the shared SQLite table or Redis namespace, and in v13 workers claim with QueueDriver::pop_with_lease so SQLite and Redis store each claim’s own stalled_after lease and recovery honours it; only claims without a lease need every sharing worker’s stalled_after to exceed the longest job_timeout among them; SQLite and Redis count stalled leases per job and fail the job at its fifth stalled lease instead of requeuing it forever (retry_failed_job restarts the count; the v13 try_with_max_stalled_leases driver builders accept 1–1,000). Worker transitions are fenced by the claimed attempt number in SQLite and Redis, so a stale lease cannot complete, fail or requeue a recovered and re-claimed job; custom drivers inherit unfenced defaults, and SQLite and Redis retry_failed_job keep the counter so the fence holds across retries. A worker without a handler for a claimed job’s name hands it back with a five-second delay in SQLite and Redis instead of failing it, until the claim’s 720th attempt fails it (section 4.3); custom drivers without requeue_attempt_after still fail it. Redis retains at most 10,000 failed jobs and 10,000 dead letters by default (1–100,000 via try_with_failure_retention), evicting the oldest atomically, and implements bounded list_all_jobs (at most 1,000 rows), retry_failed_job and purge_failed_jobs. The v13 QueueDriver::list_job_previews returns those records with each payload and error cut to a caller-chosen byte budget on a UTF-8 boundary; SQLite cuts them in SQL and Redis in one atomic Lua script, while custom drivers inherit a projection of list_all_jobs. Worker and scheduler handles buffer at most 256 undrained errors; overflow is dropped, counted by dropped_errors() and logged as a tracing warning. Server drains the scheduler handle it owns, logging each task failure, and returns a scheduler error at shutdown only for a failed scheduler loop. Custom drivers fail closed for future scheduling until implemented. TenantStorage, TenantCache, TenantRealtime and TenantPresence bind those facades to a validated TenantContext, apply immutable tenant namespaces and prove same-name local non-interference; the realtime wrappers also bound channel/event/identity names and payload size. BroadcastManager retains a channel only while it has a subscriber or an outstanding handle (publishing never creates one) and PresenceTracker counts connections per user and drops empty rooms, so neither registry grows with abandoned names. Memory and Redis caches expose an opt-in, at-most-200-entry metadata snapshot containing logical key, UTF-8 value length and remaining TTL but never the value; custom drivers fail explicitly unless they implement that method. Remote bucket policy, distributed transport/liveness, cache operator authorization and application room authorization remain deployment/application work. In a supervised debug Development process (RULLST_DEV_GENERATION set by cargo rullst dev/dash), Server also serves GET /_rullst/dev-telemetry to loopback peers with a loopback Host: bounded request counters and the newest 64 requests (method, query-free path, status, duration), outermost rullst.orm.query operation counts with the newest 16 slow operations, and the pending count of a queue passed to the v13 Server::with_dev_queue, plus the newest 16 possible-N+1 findings (an ORM label fingerprint that one request, identified by its matched route, repeated at least three times, using the shared rullst_core::query_patterns rule that Studio’s threshold also uses); bodies, headers, query strings, SQL, bindings and error text are never recorded, and release builds, Staging and Production never mount it. For deployed applications, the opt-in 13.0.0-alpha.3 Server::with_monitor serves the authenticated read-only GET /_rullst/monitor/v1 document instead (section 10.1.1).
🟢 [Implemented / Bounded]: The in-memory upload admission contract enforces a hard size/allowlist boundary, canonical tenant/name, recognized signature versus MIME/extension, active-text denial, randomized tenant quarantine keys, SHA-256 binding and fail-closed scanner release. It is not a deep parser, remote persistence or a production malware engine. Since 13.0.0-alpha.2 uploads::Multipart reads multipart/form-data under an explicit MultipartPolicy, streaming each file to memory or a private temporary file and recognizing content with the same signatures (section 4.1, “Multipart Form Uploads”).
🟢 [Implemented / Bounded]: Validated environment precedence is RULLST_ENV, legacy APP_ENV (from the process, then from the .env that Server reads, which RullstConfig::environment also honours once a Server has started), then [app].env; invalid values fail instead of silently enabling development.
🟢 [Implemented / Bounded]: apply_security_baseline and Server compose configured CSP nonce headers, exact-origin CORS with explicit credential opt-in, bounded WAF, double-submit CSRF and optional PII masking in one tested order, with the per-application config installed outside every middleware. Browser/proxy/TLS deployment evidence and application-owned session/auth/tenant/authorization remain separate. A fail-closed typed Academy boundary-assessment contract records those application observations without certifying them, and the extended rullst-security stack is still composed explicitly. The opt-in v13 Server::trusted_proxies stage resolves clients behind listed reverse-proxy networks outside that baseline (section 4.1).
🟢 [Implemented / Bounded]: client_contract exposes the portable rullst.client v1 typed JSON envelope, positive version negotiation, bounded correlation/idempotency/failure tokens, server-authored time and a fail-closed 2 MiB codec on native and Wasm. It deliberately contains no role, tenant or authorization assertion; durable replay and domain policy remain server/application work.
🟢 [Implemented / Bounded, v13]: Optional storage-s3 private S3/R2 operations passed hosted source/package admission in PR #236; final release admission and actual owner-account interoperability remain outstanding. See section 4.4.
rullst-ormActive Record & Repository patterns, parameterized SQLx connection pool (PostgreSQL, MySQL/MariaDB, SQLite), typed Turso/libSQL primary profile, schema migrations, AES-256-GCM privacy, Scout search, typed pgvector/Qdrant queries, Redis native structures, and optional capability-oriented persistence adapters.🟢 [Implemented / Bounded]: Relational CRUD, eager loading, type-safe queries, migration runner, versioned field encryption, and connection-pool resilience for supported SQLx drivers/features. PostgreSQL, MySQL, MariaDB and SQLite have distinct executable matrix contracts, while MariaDB intentionally shares SQLx’s MySQL protocol/backend.
🟢 [Implemented / Bounded]: #[derive(Orm)] #[orm(backend = "turso")] supplies typed CRUD, equality filters, ordering, pagination/counts and generated/app-assigned keys through a process-wide TursoOrm. Its migrations are ordered, checksummed, drift-detecting and reversible. The blank/API CLI profile generates, compiles, migrates, reports status and rolls back locally, while the same typed contract passes against the official remote libSQL server. Unsupported SQLx-specific model behaviors fail during macro expansion rather than being ignored. Other SQLx-specific blueprints, ORM relations/hooks, schema auto-diff, seed generation and transparent embedded-replica synchronization are not part of this bounded Turso profile.
🟢 [Implemented / Bounded]: The optional persistence boundary supplies portable document CRUD for MongoDB and SurrealDB, parameterized OLAP queries through in-process DuckDB, explicit parameterized Turso/libSQL SQL/transactions, and bounded read-only ISO GQL through SurrealDB. These capability APIs do not claim shared semantics or cross-store transactions. External adapters select deterministic offline behavior for empty or mock_* credentials where documented; SurrealDB uses its HTTP protocol rather than embedding the BSL-licensed SDK.
🟢 [Implemented / Feature-gated]: scout-http provides bounded Meilisearch, Elasticsearch and Algolia indexing/search adapters plus deterministic mocks. Meilisearch has a digest-pinned live lifecycle; Elasticsearch/Algolia have protocol fixtures, not hosted-provider certification. Generated projections are process-local post-commit effects unless the application explicitly composes the transactional outbox.
🟢 [Implemented / Feature-gated]: pgvector with strict-postgres supplies typed SQL vector helpers. qdrant supplies a separate bounded dense-vector collection/upsert/delete/cosine-query contract, while redis supplies namespaced Hash, Set and Sorted Set operations. All three have digest-pinned live lifecycles; RAG orchestration, authorization, production ANN tuning and Redis cluster/failover remain application/deployment boundaries.
🟢 [Implemented / Benchmark Evidence]: A lockfile-pinned Criterion target compares five equivalent typed-SQLite shapes through one Rullst, Diesel and SeaORM connection under the same schema, seed and SQLite policy. It is per-run evidence, not a superiority, negligible-overhead, networked-database or full-application claim.
rullst-authArgon2id password hashing, encrypted cookie sessions (AES-256-GCM), opt-in application JWTs, Passkey ceremony foundations, RBAC context guards.🟢 [Implemented / Bounded]: Non-blocking spawn_blocking Argon2id hashing, versioned expiring AES-256-GCM sessions with opt-in per-user session-epoch revocation, fail-closed RequireRoleLayer, compile-validated #[rullst::require_role], named Policy<User, Resource> decisions, and a feature-gated application JWT policy with required versioned claims, bounded TTL/scopes, strong HS256 keys, kid rotation and revocation contracts that reject process-local state in production mode.
🟢 [Implemented / Feature-gated]: sqlite supplies bounded shared local auth state. SqliteJwtRevocationStore persists JTI expiry and monotonic subject session versions through serialized transactions, stored quota/configuration and async verification. SqlitePasskeyStore persists validated public credentials, bounded device inventory/rename/revocation and optimistic signature-counter CAS; executable restart, replay, quota, corruption/configuration and two-instance contention evidence covers both stores. Authentication, role persistence, resource/tenant/device ownership, trusted file permissions/encryption, backup and multi-host replication remain application/deployment boundaries.
🟠 [Partial]: Passkey registration/assertion validates the documented ES256/none-attestation scope; the compatible PasskeyAuth challenge state remains process-local. The optional passkey-postgres v13 candidate adds a separate shared manager with bound single-use PostgreSQL ceremonies; independent-process/database-restart and Chromium virtual-authenticator contracts pass, and PR #223 passed hosted workspace/archive source acceptance. Final release admission remains separate. The v13 opaque-session inventory/logout increment extends the recovery store with process/database evidence and passed hosted source/package admission in PR #236. Final release admission remains separate. Normative WebAuthn conformance or adoption of an audited full server library, refresh tokens and complete recovery/session UX remain required before a general stable claim.
rullst-securityExplicit extended defense-in-depth layers: bounded RASP, authenticated Vault, Login Jail, Secure Headers, rate limiting, DLP and security telemetry.🟢 [Implemented / Bounded]: AES-256-GCM envelopes with rotation/AAD, bounded URI/header/body RASP heuristics, local abuse controls, CSWSH origin guard, OS-random TOTP with SVG enrollment QR, strict JSON transport inspection plus an explicitly mounted reusable JSON Schema 2020-12/OpenAPI 3.1-component policy, explicit log redaction, file-backed SRI hashes, and a versioned/bounded LiveSecurityEvent v1 dashboard envelope. DurableSiemSpool preserves the compatible unsigned local format, while AuthenticatedSiemSpool offers an explicit HMAC-SHA256-chained format with named active/historical keys, zeroized key material, sequence/predecessor validation and byte/record quotas. Restart, forgery, wrong/missing keys, reordering, interior deletion, quota, symlink and external-length-change paths fail closed. Whole valid-tail rollback requires a separately trusted checkpoint, and the caller owns directory/key trust, permissions, retention and exclusive-writer operation. Schema construction caps bytes/nodes/depth, accepts only local references, disables network/filesystem resolution and uses linear-time regexes; auth/ownership/domain rules and query/header/form validation remain application contracts. A deterministic Sentinel classifies three caller-supplied aggregate patterns and can issue HMAC-authenticated, subject-bound, expiring, one-shot process-local proof-of-work challenges; it is not AI attribution, automatic blocking or distributed replay protection. The CLI emits bounded fail-closed evidence and a CycloneDX 1.5 Cargo SBOM; it does not certify the application.
🟢 [Implemented / Feature-gated]: redis-rate-limit provides namespaced atomic Redis fixed-window counters over one lazily opened multiplexed connection per limiter (shared by its clones and reopened after a connection-level failure), hashes client keys and exposes an explicit process-local offline mode that production can reject with require_distributed().
🟠 [Partial]: Recovery-code consumption must be persisted transactionally by the application. The two-replica deployment acceptance (PR #224) shows two processes sharing one Redis rate-limit budget; Redis eviction and failover evidence is still required. CSP nonce composition is shared, but Core and Security are not yet one canonical Server stack; WebSocket CSRF tickets/frame crypto, trusted rollback checkpoints, spool compaction/remote acknowledgement and external SIEM delivery are not implemented.
rullst-aiMulti-provider LLM client (Gemini, OpenAI, Claude, DeepSeek, Ollama, explicit OpenAI-compatible endpoints), prompt injection defenses, PII masking, bounded tenant-aware RAG, guarded local tools, and conversational memory.🟢 [Implemented / Bounded]: Guarded AiClient, heuristic prompt filter, PII masking, machine-readable provider capabilities, configurable bounded live-request deadlines, a versioned deterministic injection/jailbreak/PII regression corpus, and a capability-declared OpenAI-compatible adapter. The adapter separates literal-loopback-IP HTTP(S) from HTTPS cloud configuration, supports optional Bearer authentication, disables ambient proxies/redirects, and bounds image/response bodies; unrelated protocols use AiProvider. A separate static-dispatch StreamingAiClient<P> enforces chunk/output ceilings and explicit cancellation; exact OpenAI-compatible configurations may declare strict incremental SSE with a required terminal marker and cancellation raced against request/body reads. AdaptiveAiEvaluator<P> runs caller-defined multi-turn strategies with independent turn/prompt/response/deadline limits, cancellation, typed pass/fail/inconclusive decisions and a versioned report that retains no raw prompt, response or provider error. Repository fixtures prove orchestration, not live-model behavior.
🟢 [Implemented / Bounded]: Strict URL/resolved-IP/redirect/resource policy plus an opt-in deny-by-default HTTPS fetcher with exact-host allowlist, DNS pinning, proxy bypass, peer verification and streaming limits. Explicit vision helpers accept application-admitted bytes, canonical exact-root local files or URLs only through that fetcher; capability and prompt checks precede I/O, input is capped at 10 MiB, supported image signatures are sniffed and a supplied remote media type must match. Local tool dispatch separately requires allowlist, principal authorization, closed bounded JSON, call budget, audit sink, and payload-bound approval for destructive/financial calls. RagPipeline::answer composes guarded embedding, a static-dispatch application retriever, Unicode-safe context budgets, guarded generation, source metadata, and required secret-minimized terminal audit under a trusted TenantContext; a bounded tenant-partitioned process-local cosine retriever supplies the offline contract. DurableRagAuditTrail and DurableToolAuditTrail synchronously append minimized events to distinct versioned local files under byte/record quotas and fail closed on restart corruption, symlink targets, competing-writer growth or durability uncertainty. Their SHA-256 frames detect corruption but do not authenticate events. The separate opt-in AuditDeliveryClient exports a caller-minimized, at-most-16-KiB JSON envelope with an exact HMAC-SHA256 signature, key/timestamp metadata, stable event identity across bounded transient retries, explicit cancellation and a closed event-bound acknowledgement. Cloud delivery requires HTTPS and literal-loopback HTTP(S) is development-only; the receiver still owns freshness verification, deduplication, authorization, persistence, retention and key operations.
🟢 [Implemented / Feature-gated]: StatefulChat<M> loads bounded tenant/conversation history, performs guarded generation and atomically appends one user/assistant exchange through a static ChatMemory. The bounded in-memory store is always available; sql-memory supplies fixed-schema SQLite/PostgreSQL/MySQL/MariaDB storage with an even monotonic revision and transactional compare-and-swap, so stale cross-process writers fail instead of silently reordering. Raw message encryption/retention, authenticated ownership within a tenant, provider audit, backups and conflict UX remain application contracts; the CLI scaffold remains the Turso/custom-model path.
🟠 [Partial]: The egress fetcher is not automatically mounted around provider transports, RAG or arbitrary application clients; hosted-provider SSE conformance, non-compatible streaming protocols, image decoder safety, host path trust/authorization, provider-native tool calling, cancellation for ordinary non-streaming calls, automatic provider retries, durable audit outbox/receiver operations, approver authentication, first-party external vector-store retrievers, authoritative datastore/domain authorization, ingestion/deletion, maintained application-specific eval corpora and output policy remain application or roadmap work. Exact live-model evaluation execution/results and provider behavior remain external evidence.
🟡 [Offline Mock]: Deterministic offline chat/vision/embedding fallbacks.
rullst-capitalProvider-neutral SaaS billing contracts, SaaS MRR/ARR metrics, verified webhooks, quotas and entitlements, with a supported Stripe adapter and an experimental InfinitePay adapter.🟢 [Implemented / Bounded]: BillingProvider/MeteredBillingProvider contracts, the Stripe adapter, pooled fail-closed HTTP clients, explicit mock credentials, and signature/freshness/replay foundations for the methods documented by each adapter. Applications integrate other gateways by implementing these traits (custom providers).
🟢 [Implemented / Feature-gated]: webhook-sql persists bounded provider-scoped payload digests or caller-supplied stable event identifiers across processes on SQLite, PostgreSQL, MySQL, and MariaDB. Immutable capacity/TTL, serialized claims, expiry, restart, contention, configuration drift, and fail-closed full/storage states have executable evidence. A caller-owned relational transaction can bind one semantic event claim to its domain mutation. Middleware admission and cross-system exactly-once are not implied; external effects still require an outbox, idempotent consumers, and reconciliation.
🟢 [Implemented / Bounded]: Static-dispatch metered billing uses the current Stripe Meter Events request shape, binds accepted responses to the original event, caps response bodies, forwards a bounded provider identifier and exposes deterministic non-live mocks.
🟡 [Experimental]: InfinitePay keeps deterministic offline fixtures; live plan-only checkout and live callbacks fail closed until validated against a live account.
🟠 [Partial]: Uniform live method coverage, provider-account interoperability, cross-system exactly-once, and reconciliation are incomplete.
⚪ [Removed in v13]: Paddle, Lemon Squeezy, Polar, Razorpay, Mercado Pago, Alipay, Coinbase Commerce and PicPay adapters, the Wise payout adapter and payout contracts, and the NFS-e fiscal preparation pipeline; see the migration guide.
rullst-connectSocial login / OAuth2 / OIDC providers (Google, Apple, GitHub, Discord, Microsoft, Facebook, LinkedIn, X, Auth0, Cognito and generic OIDC) with PKCE and rotating JWKS.🟢 [Implemented / Bounded]: OAuth2/OIDC clients with constant-time PKCE comparison, validated discovery, bounded JWKS refresh/cache policy (the generic OidcProvider verifies a token without kid only against a single-key JWK Set whose key fits its asymmetric alg, per OIDC Core 10.1, and treats profile claims such as name as optional), deterministic mock credentials (empty or mock_* values, served only with the mock feature; without it the fallible constructors reject them as InvalidConfiguration for credentials and the deprecated ones return a disabled provider; the mock authorization URL is the configured callback with the fixed code mock_authorization_code and the state) and a credential-free UniversalProfile projection. ConnectUser serialization omits access/refresh tokens; deserializing that form restores a credential-free profile with an empty access token. Category-aware remote revocation rejects malformed or oversized tokens before transport: Google, Discord and Apple accept the documented access/refresh categories, GitHub accepts access tokens, and Auth0/Cognito accept refresh tokens; protocol fixtures bind method, endpoint, client authentication and form/JSON shape, while request/response Debug omits credentials, bodies and URL query data. Other providers fail explicitly as unsupported, and remote success does not clear application sessions or persistence. AutoRefreshingSession<P> validates and user-binds token generations, detects expiry with a bounded early-refresh window, serializes provider refresh through static dispatch, retains/rotates refresh credentials and swaps the access token only after a complete valid response; a rejected same-user response, or a ConnectError::RefreshIncomplete whose profile/ID-token step failed after the grant (carrying the issued tokens to direct callers), still keeps its valid rotated refresh token and advances the generation so the consumed credential is never retained. Callers waiting behind a successful refresh reuse that state. Its state/leases redact secrets. EncryptedTokenSnapshot supplies a bounded, versioned AES-256-GCM envelope that authenticates key ID, provider and trusted local-account binding, preserves the validated generation and rejects copied-owner/tampered records. The optional sqlite store persists only a pseudonymous binding digest, generation/key metadata and that ciphertext under an immutable row ceiling; BEGIN IMMEDIATE, exact-successor compare-and-swap and conditional deletion reject stale shared-local writers, with restart, contention, quota, configuration, corruption, key and symlink evidence. The application still owns secret-manager key custody/rotation, account authorization, a lease around the remote provider call, running an in-flight refresh to completion (dropping the access_token() future after the grant loses a rotation), losing-call reconciliation, retry/backoff, trusted directory/backup, reauthentication and multi-host replication. The optional Axum/tower-sessions lifecycle generates a ten-minute state + PKCE challenge, adds nonce for OIDC, keeps verifier/nonce server-side, removes and immediately saves the sole active challenge before validation and rejects sequential replay/expiry/mismatch. The host still owns durable session storage and cookie/TLS/account policy; the generic session-store API is not distributed compare-and-delete, so simultaneous already-loaded callbacks require idempotent effects or a stronger application store. The v13 challenge module (from 13.0.0-alpha.3) is that stronger store without a session layer: begin_login keeps the challenge in a ChallengeStore (bounded memory, or SqlChallengeStore over the application’s SQLite, PostgreSQL or MySQL pool with the sqlite, postgres or mysql feature, which creates rullst_connect_login_challenges) under a SHA-256 digest of an opaque 256-bit ChallengeId; ChallengeCookie carries only that id (HttpOnly, SameSite=Lax, at most ten minutes, path-scoped, ambiguous duplicates refused); finish_login consumes it with one atomic removal (DELETE … RETURNING, or SELECT … FOR UPDATE then DELETE on MySQL) so exactly one of concurrent callbacks wins, refuses expired, altered and other-provider challenges, compares the state in constant time before trusting a provider error and passes the stored verifier and nonce to the exchange. ChallengeCookie::for_cross_site_post (v13) makes the cookie SameSite=None; Secure for a provider that POSTs the callback cross-site (response_mode=form_post). From 13.0.0-alpha.3 GithubProvider reports the primary address of GET /user/emails with GitHub’s verified flag (falling back to the public profile email with email_verified: None when the call fails or lists no primary), MicrosoftOidcProvider signs in with the Microsoft identity platform for an explicit MicrosoftTenant (RS256 ID token against the tenant keys, issuer bound to its tid, tenant admission, subject <tid>:<oid>, email_verified only with xms_edov: true), and AppleProvider adds verify_signing_key (the ES256 client secret can be signed) and name_from_user_field (the bounded, unsigned first-login name). Entry limits are exact on SQLite and in memory and may be exceeded by concurrent saves on PostgreSQL and MySQL. SQLite contracts run in the workspace tests; the PostgreSQL and MySQL contracts run against owned servers in CI (check-connect-sql.py). ReqwestClient also exposes explicit HTTP(S) corporate-proxy constructors, which OidcProvider::discover_with_client also uses for discovery: endpoint shape is bounded, URL credentials are rejected, authenticated remote proxies require HTTPS, system-proxy lookup is disabled and a local protocol fixture proves routing/auth headers.
🟢 [Implemented / Local Test Fixture]: The explicitly mounted Axum Mock IdP accepts only configured HTTP-loopback issuer/callback origins, binds one exact client, bounds process-local grants/tokens, consumes expiring authorization codes once, verifies S256 PKCE, signs nonce-bound EdDSA ID tokens and publishes discovery/JWKS. The deterministic key and credentials are public test fixtures; interactive login/consent, refresh/device/federation flows, durability, rotation, public exposure and OIDC conformance are not claimed.
🔵 [Roadmap]: PAC/WPAD, SOCKS, proxy mTLS identity and enterprise deployment certification are not implied. Message brokers live in rullst-messaging, not this OAuth-focused crate.
rullst-messagingBroker-neutral event envelopes, idempotent publication, consumer groups, acknowledgement leases, retry, dead letters, durable local SQLite state, and future remote adapters.🟢 [Implemented / Bounded Foundation]: rullst.messaging.v1 envelopes, bounded identifiers/headers/payloads/batches/retention, topic-scoped exact-replay idempotency, fan-out between groups, competing consumers, expiring single-use ACK leases, bounded retry/attempt ceilings, dead-letter views, explicit terminal purge, injectable time and a reusable static-dispatch contract suite. Debug output redacts keys/tokens/header values/payloads. A canonical bounded v1 envelope codec rejects unknown versions, non-canonical/truncated/oversized frames and namespace mismatch; a deterministic digest fixture freezes its bytes. Validated W3C version-00 traceparent and a conservative tracestate subset propagate through only those two allowlisted headers; arbitrary baggage, sampling and export remain host work. InMemoryBroker remains deterministic/process-local. The opt-in SqliteBroker uses a fixed schema and serialized BEGIN IMMEDIATE mutations for publications, subscriptions, claims, ACK/retry/DLQ and purge; exact limits are persisted per namespace. Its explicit AES-256-GCM profile encrypts header values plus payload with randomized nonces and AAD binding to immutable row metadata. A bounded primary/prior-key ring rejects missing keys until old records are purged, and plaintext/encrypted profiles cannot mix. The opt-in static OrmOutboxRelay binds one relational outbox stream to one topic, validates claimed JSON, publishes the durable event key scoped by its stream (SHA-256 of the stream, /, event key) as broker idempotency, so streams sharing a topic never deduplicate against each other, and only then ACKs the exact ORM lease; a publish-before-ACK crash/reclaim test produces an exact replay and one broker message. Shared-contract, raw-storage/restart, wrong-key/tamper/row-swap, symlink, rotation, expired-lease, two-instance contention, configuration-drift and malformed-row repair regressions are executable.
🟠 [Partial]: Delivery is at least once. The default profile is plaintext. Even in the encrypted profile, topic/event/content metadata, IDs, timestamps, idempotency keys, fingerprints, rotation key IDs and delivery state remain visible; key custody, permissions, backup/rollback detection, retention, disk operations, topic/tenant authorization and destination-side idempotency belong to the host. Profile migration requires a new namespace/database and application-owned republishing. The outbox database and broker publication are not one atomic transaction; worker supervision, cleanup and destination idempotency remain application work. The local adapter does not provide replication or automatic failover. The envelope codec is not a remote transport and does not preserve caller publication keys or broker acknowledgements by itself. The optional standalone Redis Streams candidate passed source admission in PR #236, with Rullst-owned indexes, verified TLS, exact replay, server-time lease fencing, partial-write quarantine and disposable restart/fault/outbox tests; final release admission remains separate (see the Redis messaging guide below). Kafka, RabbitMQ, NATS/JetStream, SQS/SNS, Google Pub/Sub and Pulsar adapters, replication/failover and native Redis group interoperability remain roadmap work.
rullst-iotno_std sensor telemetry/protocol helpers and an Ed25519-signed firmware-manifest verification gate.🟢 [Implemented / Bounded]: Ed25519 manifest verification with target/hash/length/counter checks, an explicit durable monotonic-CAS store boundary, no_std telemetry/frame models, bounded MQTT 5 PUBLISH and RFC 7252 CoAP base-request encoders, a credential-free local HTML snapshot renderer, and a safe telemetry-module CLI scaffold. Protocol vectors and restart/retry/conflict tests prove these local contracts, not a broker, network or physical device.
🟠 [Partial]: GPIO state, I2C/Modbus frames, BLE GATT records, RSSI topology, power recommendation and Digital Twin JSON are data/helpers only, not hardware, network or realtime drivers.
🟡 [Simulador Dev]: Deterministic MQTT-value/HSM/PQC fixtures require feature = "experimental-simulators" and never represent broker or cryptographic capabilities.
🔵 [Roadmap]: Concrete hardware-backed counter/boot integration, firmware download/flashing, MQTT/CoAP transports and state machines, hardware drivers/HSM and audited ML-KEM.
rullst-mailTransactional email engine with Resend, SendPulse, optional SMTP (implicit TLS on 465, mandatory STARTTLS on other ports), optional native AWS SES v2, and offline fixtures.🟢 [Implemented / Bounded]: Mandatory pre-flight pipeline, anti-CRLF validation, a 2 KiB subject and 2 MiB per-body bound enforced by rejection before linear-time disposable-domain/security/DLP heuristics, provider-specific transports (other providers are application-owned MailDriver transports; v13 removed SendGrid, Postmark, Mailjet, Mailtrap and ACS), seven safe scaffold variants (including bounded commercial payment receipts and explicit D+1/D+3/D+7 dunning), and expiring purpose-bound HMAC tracking tokens. TenantMailResolver selects an in-process driver directly from an explicit authenticated Core TenantContext; invalid IDs and unavailable registry state fail closed, and tests prove two contexts do not cross-deliver. MailError classifies permanent/transient/rate-limit outcomes; the in-process FailoverDriver forwards tenant context and delivery IDs to each driver it tries and sends another provider only transport/HTTP 5xx/429/transient-SMTP failures, stops at a fallback’s permanent or fail-closed guard error and returns it typed, captures bounded delta Retry-After, fails closed on circuit-state errors and emits structured tracing without provider response bodies. The Mail facade fills a missing sender from a validated MAIL_FROM/[mail] from default (v13) and real transports never invent one. Mail::enqueue preserves tenant and bounded due-time metadata through SQLite/Redis without early claims; the worker consumes that timestamp only once it is due, allowing at most 300 s of worker clock lag behind the queue server. Direct Resend retains provider-native scheduling and rejects schedules beyond its documented 30-day window before network, while real SMTP/SendPulse/Log and SES paths reject future direct delivery; offline fixtures may retain it for assertions. The shared attachment contract accepts at most 32 items, 20 MiB each and 25 MiB raw aggregate; validates safe basenames, parameter-free MIME and unique HTML-referenced CIDs; redacts bytes from Debug; and feeds provider-native Resend, SendPulse (non-inline), native SES, the SES v2 bearer-proxy Simple.Attachments payload and nested SMTP MIME serialization. The opt-in static AttachmentInspectionGuard fails before transport on executable magic, executable/script-host extensions, spoofed known types, active PDF/SVG, recognized secrets and unsafe text links, choosing checks from the case-insensitive declared type, filename extension and content signature together, and classifying markup by root element and namespace as browsers parse it (strict mode also rejects HTML extensions/markup, script URIs, unknown extensions and declared types it does not inspect other than application/octet-stream); external scanners can implement the same contract. The provider-neutral SuppressionGuard checks process-local or opt-in shared-local SQLite state before transport, keyed by the bare address that one recipient parser extracts for the pipeline, the disposable-domain check, verified feedback and every store (display-name forms are normalized, internationalized domains are keyed by their IDNA A-label, unparsable forms and non-ASCII local parts fail closed as validation errors); verified event identities are replay-bound, suppression reasons escalate monotonically and immutable quotas are transactional. ObservedMailDriver emits only a bounded provider label, terminal outcome, latency, attachment count and scheduling/tenant booleans through a non-failing observer. With aws-ses, AwsSesDriver sends SES v2 Simple messages through the official AWS SDK and SigV4, including temporary credentials, caller-owned rotating providers/config, HTML/text, RFC 8058 headers and attachments/CID; it rejects provider field limits and an encoded estimate over 40 MiB before network, caps Retry-After, and a loopback contract asserts the signed regional ses/aws4_request request plus typed/redacted rejection. The legacy constructor remains only an offline-fixture or explicit trusted bearer-proxy boundary, never an unsigned AWS request; the proxy payload carries attachments/CID under the same SES field and 40 MiB bounds instead of dropping them. Payment receipts never claim tax authorization; dunning does not infer billing state or scheduling.
🟠 [Partial]: Exact execution time, exactly-once delivery, live-account SES acceptance and inbox delivery are not implied. The local attachment inspector is not antivirus, sandboxing, recursive archive inspection or CDR; provider/account limits may be tighter. Provider webhook authentication/adapters, multi-host suppression replication, file encryption, distributed breaker/telemetry operations, durable encrypted tenant credentials, rotation and cross-process distribution remain application/deployment concerns; tracking payloads are authenticated but not confidential. SES identity/domain verification, sandbox exit, IAM least privilege, quotas, reputation and provider operations remain AWS/account/deployment work.
🟡 [Offline Mock]: Memory/Log plus empty or mock_* provider credentials; SMTP only for an empty/mock_* host or a mock_* credential, so a real host without credentials is a real unauthenticated relay and partial SMTP credentials are rejected.
rullst-studioLocal Developer Control Room (http://127.0.0.1:5555), clean route navigation, live system telemetry visualizers.🟢 [Implemented / Bounded]: Local control center, RadarSnapshot telemetry, database/migration surfaces when configured, and explicit Unavailable states for unconnected probes. The data browser reads/filters SQLx tables with 25-row pages, 256-character cells and 256-byte search terms and, only after the verified debug-loopback/same-origin middleware installs an unforgeable request marker, can update primitive non-key values or delete exactly one complete-primary-key-selected row. A table whose key includes a column outside the identifier boundary or column cap stays read-only, and each write commits in a transaction only when exactly one row changed. Values are bound, request/schema/value cardinality is bounded, backend-specific types remain read-only and SQLite/PostgreSQL/MySQL/MariaDB have executable mutation contracts. This is not application tenant/RBAC, audit, rollback or shared-production administration. The supplied queue snapshot exposes only backend records; SQLite can explicitly retain 1–100,000 successful jobs with atomic pruning and purge while deleting them by default. Retained payload access/policy belongs to the host. An explicitly supplied memory/Redis Cache exposes at most 100 metadata rows in the UI; logical keys become process-bound HMAC tokens, values never leave the driver, and only individual local invalidation is available. A separately mounted push-only trace router accepts 1–128 attribute-free v1 spans under 128 KiB after HMAC-SHA256, source/ID/clock/nonce validation and atomic replay rejection; the bounded in-process viewer derives slow-query and repeated-label heuristics without SQL or bindings. It is not OTLP, durable trace storage, a key manager or remote Studio authentication. Successful feature-flag toggles invalidate all warm DbFeatureDriver caches in the same process through a constant-size epoch. Cross-process/direct-writer invalidation remains TTL-bound unless the application distributes the signal.
rullst-nexusAuto-generated Admin CMS (/nexus), dynamic model CRUD, AI Admin Assistant (/nexus/chat), SOC Threat Radar.🟢 [Implemented / Bounded]: #[derive(Nexus)] emits registered named-field metadata with inferred primitive or explicit semantic widgets; the panel provides parameterized CRUD/search/sort/pagination plus bounded selected-record delete/deactivate. Construction is fail-closed, requires an authentication policy and admin role layer, validates bounded unambiguous model/field/enum/relation metadata, enforces server-side field policy, caps form pairs and field bytes, rejects unknown/protected/duplicate or semantically invalid form values, minimizes database errors returned to clients, and escapes record/model metadata on audited paths. Boolean widgets are inferred; enum options and multiline intent are explicit because an unrelated Rust field type does not expose those semantics to the struct derive. Deactivation requires a writable Boolean is_active/active. In v13 an opt-in application policy admits each request through the host’s own sign-in (NexusAuthorizer), models may declare composite primary keys, and date/time fields bind typed values that native PostgreSQL/MySQL date and time columns accept.
🟢 [Implemented / Opt-in Bounded]: a registered text tenant column scopes every built-in read, create, update, delete and batch operation to a trusted Core TenantContext; create injects the context value and missing context fails closed. with_required_audit transactionally couples successful mutations to a minimized fixed-schema row containing the built-in authenticated actor, optional tenant, table/action, optional known key, count, committed outcome, correlation ID, timestamp and format version; missing audit storage rolls back the mutation. The audit table is in the same relational database, mutable by its administrators, records no denied attempts, and may omit an automatically generated create key. Host identity/membership/domain policy, global-model and custom-route authorization, database privileges, schema/type compatibility, retention/backup/replication and immutable external audit delivery remain application/deployment contracts.
rullst-macrosProcedural macros (html!, #[server_function], #[island], #[live_component]/#[live_event], #[memoize], #[require_role], #[derive(Billable)]) and the deprecated #[route] compatibility marker. ORM derives live in rullst-orm-macros; rullst::runtime::main re-exports Tokio’s entry-point macro.🟢 [Implemented / Bounded]: Compile-time html! escaping into composable Markup with explicit RawHtml, role/billing/live/memoize attributes, and trybuild diagnostics. A concrete async #[server_function] returning RpcResult<T> generates a matching explicit native router and Wasm caller over the bounded rullst.client v1 JSON envelope: owned Serde parameters/results, same-origin /api/rpc/... path, 256 KiB request/response policy, request correlation, media-type/version/schema checks, CSRF-cookie forwarding and message-free failure codes. The host must mount the route inside production security, authenticated identity, tenant, authorization and rate-limit layers; application idempotency and browser/network interoperability beyond CI are not inferred. #[island] hydration remains experimental.
cargo-rullstDeveloper CLI toolkit, scaffolding generators (make:*), three project starters (Blank, API, SaaS), AST IDOR static route scanner.🟢 [Implemented / Bounded]: Interactive wizard, generators, heuristic IDOR scanner, CycloneDX exporter, toolchain doctor and a fail-closed Academy evidence diagnostic that explicitly does not certify a deployment. Deterministic generation can explicitly select the starter (`–blueprint blank

The matrix omits rullst-orm-macros, which ships the ORM derives described in section 5, and the Experimental v13 crates rullst-privacy, rullst-supervision, rullst-media and rullst-labs (all four in the release inventory and first published in the 13.0.0-alpha.1 prerelease); the v13 sections below specify their boundaries.

v13 roadmap package boundaries

The rullst-labs library is an implementation candidate in the 13.0 release inventory, first published in the 13.0.0-alpha.1 prerelease; final release acceptance remains outstanding. It is a bring-your-own-runner library: it owns trusted, versioned orchestration and grading contracts and never executes learner code. The separately deployed rullst-labs-runner candidate (a Linux Rust-to-Wasm/Wasmi executor that passed source admission in PR #228, with recorded hosted evidence) was removed from the workspace for 13.0; its source remains in git history. Applications deploy their own runner against the documented controller contract. Neither rullst-labs nor any runner may become a default framework dependency, execute learner code inside the HTTP process, or require the application to expose a container control socket. A complete offensive CTF arena is external, separately governed deployment infrastructure even when it uses Rullst identity, challenge, score and receipt contracts.

Protocol version 1 defines one profile: a bounded Rust pure-function exercise (solve(i64, i64) -> i64) whose identifiers name a pinned Rust toolchain, import-free WebAssembly and a pinned Wasmi interpreter. This is not a native Rullst server, Cargo dependency, WASI or arbitrary shell profile. rullst-labs must not depend on an executor or spawn submitted code; the workspace boundary check rejects an execution-engine dependency in any member. Its default surface is validated contracts; opt-in shared-local SQLite stores dedicated, encrypted job content with current application authorization, idempotent submission, cancellation, leased execution, retention and result reconciliation. It must not reuse the application’s authentication/database secrets as job keys. Keep both existing 90% line-coverage floors; rullst-labs remains in the framework-library aggregate.

The application-owned runner’s trusted controller accesses only that dedicated job plane, its receipt signing seed and runner-owned tools. It must never give submitted code the job database, signing keys, expected answers, application secrets, inherited environment, network or control sockets. Keep the content key and signing seed in owned private regular files under protected ancestors, and validate the opened no-follow descriptor and exact key length. Execution isolation, resource limits, observed enforcement and teardown are host responsibilities: rullst-labs binds pinned tool digests and verifies signed receipts but cannot check that isolation happened. Unsupported environments must fail closed without a less restrictive execution fallback. The removed candidate’s first-profile threat model records one reviewed Linux design (delegated cgroups v2, unprivileged namespaces, seccomp, separate Landlock domains and observed probes) as a reference baseline.

Only an integrity-bound result matching the current job/lease, tenant, learner, exercise, grader, toolchain, source and execution profile may become a grade. Expected answers remain in the trusted grader; worker outputs are bounded data, not a passing-grade authority. Cancellation/expiry fence late results; worker loss never means success. Offline simulation is explicit and cannot establish execution evidence. Authorized course maintenance must expire queued submissions and remove their source without requiring a supported/available executor. It must not clear a running lease or infer teardown. Withdrawn exercise revisions may be removed only after every referencing job has been purged; immutable revision identifiers must not be reused after removal. Independent isolation review of the deployed runner and the roadmap’s adversarial acceptance remain required before any production-ready untrusted-code claim.

v13 managed-video implementation boundary

The optional rullst-media candidate, first published in the 13.0.0-alpha.1 prerelease, owns managed private video, starting with Bunny Stream. This differs from Core’s object-storage facade: remote video creation, upload capabilities, asynchronous processing, playback grants and lifecycle reconciliation require a separate explicit domain. It has no Core/Auth/ORM runtime dependency and no default network/database features. Core is a test-only dependency for the real HTTP/browser security composition. bunny selects the reviewed HTTP/signature adapter; sqlite selects durable shared-local asset/operation state and static-dispatch application orchestration. PRs #227 and #239 supply hosted source and extracted-package evidence. The standalone package now enters the v13 release inventory, without a facade dependency. It was first published in the 13.0.0-alpha.1 prerelease; final release acceptance remains outstanding. Actual Bunny account/CDN interoperability remains explicitly unvalidated.

The host supplies authenticated actor/course membership and current management or playback entitlement through a checked authorization trait. Local asset IDs bind an immutable tenant/course/provider-library scope; provider IDs and signed webhooks never establish that ownership. Service operations recheck permission and local revision/state after external work and use bounded durable leases to reject concurrent/stale mutation results. Playback is a read: it takes no lease, so viewers never wait on one another’s provider reads and an abandoned request leaves no intent; its short local transactions still serialize, and a lock or connection wait over 5 seconds is Busy; it fences withdrawal/deletion in the transaction that issues the grant. SQLite state binds its schema, provider mode, library, store-wide capacity and an optional per-tenant asset quota for shared stores, refuses clock rollback and requires trusted local files, backup policy and operator-owned keys. Multi-host replication is separate work. Only confirmed-deleted or explicitly discarded local tombstones may be purged, in batches up to 100 and after at least 24 hours; the host must retire purged creation IDs because their idempotency memory ends at that point. Tombstones keep no digest of the deleted title or description. Provider backups/cache erasure is separate.

Creation is journaled before remote dispatch. Bunny’s documented creation API does not supply an idempotency key: ambiguous creation must reconcile a persisted random opaque creation marker, never blindly retry or claim exactly-once remote creation. Updates/deletion are reconciled against authoritative reads; a metadata update writes and verifies the same documented description meta tag, never Bunny’s separate top-level Description that Smart Generate may rewrite. A create or update that fails for a non-transient reason (definitive refusal, unconfirmed marker search, missing remote video, full tag list or failed verification) stops with a bounded non-secret reason instead of staying pending; it is never retried automatically and an authorized host explicitly retries or discards it. Retrying an unconfirmed creation repeats only the marker search; discarding a creation leaves a local tombstone and may orphan an undiscovered remote video. Webhook v1 authenticates exact body bytes with the read-only library key but has no signed timestamp; bounded durable duplicate suppression and serialized provider refresh prevent replay/reordering from granting access or publishing assets. Webhook status numbers and API video status numbers are distinct protocols.

Publication is an explicit application mutation after current provider readiness; processing completion alone cannot publish. Playback requires current entitlement, fresh ready state and configured provider protection; withdrawal/deletion stop new grants but cannot immediately invalidate previously issued bearer tokens or provider caches. Upload grants are scoped and expiring, not cryptographic proofs of a one-shot upload, file type or byte limit. Library upload quotas and direct file protection remain required configuration. Embed SHA-256, TUS SHA-256 and CDN advanced HMAC-SHA256 directory tokens have independent documented formats. Redirects/ambient proxies/arbitrary remote endpoints are disabled; request, response, retry and total-operation budgets are finite. Secret-bearing errors, credentials, grants and debug output must not leak keys or bearer URLs.

Empty or mock_* credentials select deterministic offline behavior; mixed modes fail configuration. Production rejects mock or loopback-fixture capabilities. Automated protocol/browser/disposable-state evidence is required. The owner’s no-live-account-testing instruction remains in force: actual Bunny account, transcoding/CDN interoperability and paid DRM are not validated or implied. See the supported delivery target.

The experimental s3 feature adds S3-compatible object storage (AWS S3, Cloudflare R2, MinIO) behind the same provider, grant and SQLite contracts. It stores and serves the original object without transcoding. Keys are server-generated; uploads use host-authorized presigned PUT grants bound to a declared content type and exact length, playback uses presigned GET (PlaybackKind::Original), both expire within 900 seconds, and HEAD confirms size and type before an asset becomes ready. Signing reuses Core’s aws-sigv4 signer, not a second S3 SDK. It is validated against the offline mock and a loopback protocol fixture only; provider interoperability is not yet validated. See S3-compatible object storage.

v13 privacy and age-assurance boundary

Core and Security header layers share apply_referrer_policy: a response that explicitly supplies a canonical no-referrer value retains that restriction through composition, normalized to one header even if duplicate values exist. The Security layers replace other values with their configured policy; missing/invalid optional Security configuration retains its existing behavior. The Core baseline adds each security header, including Referrer-Policy, only when the response has none, so an explicit application value wins over it, and it leaves every security header alone on a response that an inner SecureHeadersLayer marked with SecurityHeadersApplied (omitted headers stay absent; Cache-Control: no-store and the shared CspNonce are unchanged). This narrow rule does not attempt to order every Referrer-Policy value.

rullst-privacy is an opt-in v13 release candidate, first published in the 13.0.0-alpha.1 prerelease. Its initial age-assurance feature owns bounded risk policies, server-issued challenges, minimal signed age attestations, explicit decisions and replay-store contracts. Low-risk declarations, facial estimates and verified age attributes have different assurance semantics; configured policy determines their eligibility. No camera, image retention, external inference, database or Core dependency is enabled implicitly. Production must reject offline mock evidence and process-local replay protection. The host owns authentication, tenant/subject binding, risk/legal assessment and durable shared state.

The v13 persistence contract uses static-dispatch asynchronous nonce claims. Verification samples a trusted server clock before validation and after the claim; expiry or clock rollback during storage cannot return permission. A claim that the high-water check rejected only because a concurrent request committed a later second is repeated, at most twice, with a fresh sample that has advanced past the rejected one; the check itself is never relaxed. The optional sqlite adapter is shared-local only: a private file-backed pool, WAL with full synchronization, serialized quota/expiry/claim transactions and persisted configuration/clock high-water state. It stores only domain-separated nonce digests and expiry. No unexpired claim may be evicted. Cancellation or an uncertain commit returns no assessment; the host must request fresh evidence when consumption is uncertain. File custody, clock synchronization and storage durability are deployment obligations. Restoring an older database requires quiescing verification and invalidating all outstanding challenges through a new policy/key epoch; SQLite cannot detect arbitrary backup rollback. Network filesystems and multi-host replication are outside this adapter’s boundary.

The v13 PostgreSQL adapter uses a separately enabled postgres feature and a private bounded pool against one authoritative writable database. Explicit deployment initialization creates the fixed rullst_age_replay schema; normal connection never creates or repairs missing state. Transactional startup locking and a metadata row lock serialize schema initialization and quota/expiry/nonce claims across application hosts. Metadata persists the schema version, capacity and accepted clock high-water mark. Tables must be permanent and WAL-logged; fsync, full-page writes and synchronous commit are required. Claims store only the same domain-separated nonce digest and expiry as SQLite. Remote connections require certificate/hostname-verified TLS; loopback and local sockets support disposable development databases. Errors must not expose connection credentials. Bounded pool acquisition, statement/lock waits and idle transactions prevent unbounded database waits; the caller still owns an overall request deadline. All verifiers must use that same authoritative database and synchronized trusted clocks. Replication configuration, failover fencing, durable hardware and backup restore remain operator obligations; asynchronous replica promotion or database rollback cannot be advertised as preserving consumed proofs automatically. The adapter’s focused real-database tests cover concurrent initialization and claims, a restricted runtime role, quota, schema/durability drift, cancellation, clock changes during a row-lock wait and server restart. Combined hosted acceptance and deployment-specific failover/TLS evidence remain separate.

This first contract does not implement a facial model, vendor transport, guardian verification or global privacy compliance. The privacy and age-assurance roadmap defines the remaining consent, rights, retention, provider and jurisdiction work. The owner-approved 13.0.0 delivery scope prioritizes the independent foundation and an authenticated first-party declaration journey for policies that permit that assurance. An external provider is optional and requires separate native protocol and sandbox acceptance before any live claim. A local facial engine is follow-up work. Neither an absent provider nor a failed check may downgrade the required assurance or make a declaration authorize a stronger-policy action. Publication of the privacy package still requires explicit acceptance of its advertised API, durable state, consumer behavior and release configuration.

The release inventory (.github/release-order.json, 20 packages) lists this independent package before the umbrella. The facade exposes only explicit privacy-* features and rullst::privacy; default builds acquire no privacy, age, consent or database dependency from this addition. CLI age/privacy consumers select the matching registry version by default, with an explicit matching local source override for development. Existing dependency sources, versions and features must be checked before composing two consumers; no silent source switch is permitted. Both commands refresh the bounded project context after scaffolding, reporting a refresh failure without concealing the completed source edits. Archive-only acceptance must install the packaged CLI and compile generated SaaS consumers with both opt-ins and no workspace source paths.

The native DeclarationGate contract processes an authenticated first-party AgeDeclaration without requiring an external issuer or a signing key for the declaration itself. It accepts only a retained server-issued SelfDeclaration challenge under the exact current policy and tenant/subject/session/action binding. It shares the signed verifier’s durable one-use claim and post-storage expiry/clock checks. An affirmative answer can return only Assurance::Declared; negative or declined answers deny the operation. An estimated/verified method or stronger policy cannot be downgraded through this entry point. The host owns authentication, CSRF protection, explicit user choice and trusted challenge retention/transport. This contract is not a determination of the person’s age.

The optional challenge-tokens transport uses a bounded, versioned, HMAC-SHA256-authenticated server challenge with an explicit active key and at most seven previous verification keys. It authenticates the version, key ID and exact encoded payload before decoding challenge JSON. Opening revalidates the current policy, authenticated binding, clock and exact configured lifetime. There is no client-supplied algorithm or key discovery. Tokens contain only the existing opaque challenge references and are authenticated, not encrypted; applications must not put cookies, email addresses or document numbers into those references. A token restores a server-issued challenge, never an age decision or an authorization grant. Production still consumes its nonce in the shared durable replay store. The host owns independent secret provisioning, rotation/retirement, TLS, CSRF and endpoint limits; no external age provider is needed for this transport.

The v13 make:age-gate consumer is an explicit opt-in for the recognized SaaS starter. It protects the existing dashboard action with an authenticated GET challenge and POST declaration; it never sets a reusable age-verified account flag. Both routes retain the starter’s authentication, CSRF, headers and Server baseline. Policy version and threshold are chosen explicitly at generation time; the SaaS profile also requires its fixed application tenant. Low-assurance declarations are identified as such. Opaque binding references are domain-separated keyed digests of server-resolved user, tenant and authenticated session, never request-supplied identities. Application keys and replay storage are mandatory; initialization, timeout, stale context or unknown/negative answers cannot grant access. SQLite and PostgreSQL are explicit profiles with their existing deployment obligations. Generation refuses unknown or already-modified route shapes before writing. This command selects the matching registry dependency unless the caller explicitly supplies a matching local source (--privacy-source). Other blueprint adapters, provider flows, persistent age permissions and deployed browser acceptance remain separate work.

v13 removed this generator’s LMS target together with the complete Academy scaffold; the LMS example (the former LMS starter) has no school membership to bind.

The v13 fuzz inventory adds two isolated privacy targets for authenticated challenge transport and signed attestations. Deterministic fuzz-only keys permit mutation beyond signature validation; assertions bind accepted results to the current context, assurance and one-use replay contract. These targets enable no database or network integration. They expand v13’s required inventory to 42; the immutable v12 release line retains 40. Short diagnostics are not full release evidence, and the durable database lifecycle keeps its separate acceptance gates.

The v13 consent contract is independent of age assurance and concerns only optional processing that the operator has assigned to an explicit purpose and notice version. Absence, refusal, withdrawal, version mismatch and expiry deny processing. An affirmative choice uses the exact observed revision; withdrawal atomically advances that revision even when its form is stale, preventing an earlier affirmative form from undoing it. The authenticated subject and tenant are server-owned inputs, never browser identity fields. The explicit submission also binds the displayed purpose/notice version to current server configuration, so a policy change cannot borrow an old form’s unchanged revision. A bounded store must serialize reads/updates against a persisted clock high-water mark and preserve withdrawal tombstones; production rejects process-local state. The gate retries an operation that lost the lock to a later second the same bounded way. Permission must be checked immediately before each processing action, including deferred jobs. Already-started external effects are not cancelled retroactively by a later withdrawal, and consent does not authorize essential processing or establish age/guardian authority. The optional consent-sqlite adapter initializes a new local file explicitly; ordinary opening never creates or repairs missing state. It serializes reads/updates with BEGIN IMMEDIATE, WAL/full synchronization, a bounded private pool, immutable quota and persisted clock metadata. It retains scope digests, latest choices/versions/revisions and timestamps, without raw subject IDs. Those digests are pseudonymous data. Expired records and withdrawal tombstones are never evicted to create capacity. All processes must use that same trusted local file; multi-host replication is unsupported. Restoring stale state requires quiesced processing, reconciled withdrawals and fresh purpose versions. Deployment retention/restore review and hosted acceptance remain required before release admission.

The owner-selected PostgreSQL consent increment passed combined source admission in PR #239; final release admission remains separate. Its separate consent-postgres feature exposes PostgresConsentStore::initialize for deployment bootstrap and connect for ordinary runtime access. Both accept a connection string and immutable capacity. One authoritative writable database owns the fixed rullst_consent schema. Explicit initialization serializes concurrent bootstrap without resetting existing state. A private bounded pool enforces verified TLS for remote TCP, fixed name resolution, synchronous commits and bounded acquisition/SQL waits. Every read/update validates permanent WAL-logged tables and locks the metadata row, serializing clock observations, quota and revision changes across hosts. Withdrawals remain durable tombstones; no expiry/capacity cleanup removes them. Stored subjects remain domain-separated digests. Replication fencing, clock synchronization, hardware durability and stale-backup reconciliation remain deployment obligations. The facade feature privacy-consent-postgres must not enable age assurance or add a database dependency to default builds. The existing CLI privacy consumer remains explicitly SQLite until separately extended; an ordinary typed application can use the PostgreSQL store directly. Disposable-database concurrency, restricted-role, cancellation, clock, restart, consumer and hosted acceptance are required before completion is claimed.

The opt-in make:privacy consumer mounts authenticated preferences, an explicitly optional personalized greeting and an own-account JSON export inside the starter’s existing CSRF/header/security boundary. It composes with the age consumer while preserving both consumers’ dependency features. Choice forms bind the displayed notice/version and current revision, with a bounded expiring HMAC proof tied to the authenticated account, tenant and session; an old tab cannot apply a choice after switching accounts or sessions. Identity comes only from the authenticated user and the fixed application tenant. The export projects only account ID, name and email with explicit output bounds; it exposes no credential fields, produces no public artifact and does not mark broader queued rights requests as fulfilled. Its direct private response needs no retained export file and remains independent of optional-consent storage availability. Refusal/withdrawal changes the next greeting to generic content. Local materialized SaaS fixtures cover both age/privacy installation orders and both dependency sources, real authenticated profile queries, explicit choices, withdrawal/stale forms, session/account changes, CSRF, expiry, bounded input and missing or failed state. A normal SaaS using PostgreSQL as its primary database compiles; this is not live PostgreSQL consumer or browser/deployment acceptance. Bootstrap explicitly initializes a new private consent file; ordinary opening never recreates it. The generated consent profile remains shared-local SQLite only.

The separate SaaS triage assigns the examples’ reported defects to compatible v12.1 maintenance and v13 contracts; it is not fix evidence.

v13 source-train and adoption preparation

The development train uses one version, currently 13.0.0-alpha.4, for every publishable package, and exact (=13.0.0-alpha.4) internal requirements, so Cargo cannot pair packages from different prereleases. This prepares the CLI’s actual major-version behavior; it is not a publication or stable-release claim. The privacy package joins the candidate inventory with explicit optional facade features; it was first published in the 13.0.0-alpha.1 prerelease on October 11, 2026.

The rullst-upgrade-rules-v4 migration catalog recognizes source majors 12 and 13 and carries the v12 → v13 source rules (see the assisted upgrade contract); v5/v6 applications, including their v11-era ecosystem crates, first upgrade to v12 with the v12 CLI. It keeps exact target-major CLI selection and rejects downgrades. Preparations from a previous catalog require fresh preparation and verification. The 12.1-to-13 source inventory adds opt-in privacy APIs, consumer generators and security header composition, and lists the changed APIs and behavior that the catalog reports as must-change or review findings. Automatic preparation remains limited to supported dependency manifests and the existing compiler/check/test workflow; it must not invent code rewrites, enable age or consent policies, bootstrap privacy state, replace authentication or deploy an application. Source fixes stay with the developer, guided by each finding’s migration row (section 12). Migration evidence must distinguish small offline updater protocol fixtures from generated SaaS consumers and the workspace examples compiled against actual framework source. Release-time inventory and package checks must revisit this boundary as more v13 changes land.

13.0 intentionally removes supported commands, features and providers, among them make:omni, cargo rullst omni and the offline-sync feature (section 13); the Foundry, Nix, Buildah and gRPC generators and the Fly, Render and Railway deploy targets; the self-updater’s binary replacement (section 12); cargo rullst ai (section 11.3); the Capital providers of section 6.4 and the Mail transports of the v13 Mail provider scope below; and the LMS, Blog, Portfolio and ERP starters, now applications in examples/. The migration guide lists every break; retain each in the migration inventory.

v13 formal-verification pilot boundary

The Verus pilot has a locally verified candidate for production age-policy method decisions, with later evaluation of Auth authorization predicates and Capital integer money calculations. Specifications must remain linked to the executable implementation, with explicit trusted assumptions and external contracts. No dedicated public crate is proposed. A pinned, isolated verifier and manual workflow precede any required v13 check; compatibility, reproducible proofs, negative controls and measured CI cost are promotion criteria. This plan adds no v12.1 release gate or framework-wide correctness claim.

The first implementation candidate verifies the existing AgePolicy::permits body with its actual enum variants and policy fields, extracted from Rust syntax. No public API or production dependency changes are required. Extraction must check the reviewed signatures/types and original structural equality derives, copy the executable body unchanged and record source/type/body fingerprints. Only verifier annotations, structural-equality support, a specification accessor and an erased reveal step may be added to the projection. The proof covers all nine risk/method combinations without narrowing valid runtime inputs; it does not prove age evidence, policy validation, timestamps, storage or authorization. The isolated verifier must reject weakened restricted/elevated policies and a denied low-risk policy as negative controls. This is a bounded pilot candidate, not a promotion into required release checks.

v13 shared passkey ceremony increment

The next Auth increment follows the shared ceremony contract. Keep the existing synchronous process-local API compatible. Add a separate generic shared manager in rullst-auth, backed first by opt-in authoritative PostgreSQL, with tenant/account/session/RP binding, bounded expiry/quotas, atomic single use and fail-closed outage/cancellation. Reuse private cryptographic validation; do not expose a stateless acceptance bypass. Application credential ownership, revocation and signature-counter CAS remain mandatory before session issuance. Real independent-manager/database/process and HTTP/browser acceptance must precede an implemented claim. No new crate or broad blueprint expansion is required.

v13 deployment acceptance increment

Before expanding deployment generators, exercise two independent application processes behind one digest-pinned existing reverse proxy. Keep readiness, unhealthy-node exclusion, bounded shutdown, forwarded identity and WebSocket behavior explicit. Ordinary HTTP admission must remain counted until the response body completes, errors or is dropped, including streaming data and trailers; returning response headers alone is not completion. This counts application body lifetime, not client receipt or TCP acknowledgement. Upgraded connections and background tasks require separate application-owned termination. Prove the body boundary locally before using it in deployment acceptance. No new gateway crate, automatic host administration or general availability guarantee is introduced.

v13 local deployment diagnostic

cargo rullst deploy:doctor is an offline, read-only command of the main CLI. It inspects the selected bounded Rullst.toml snapshot (--config, or documented defaults) and optionally one explicit literal environment file (--env-file) or the three allowlisted process variables RULLST_ENV, APP_ENV, APP_KEY (--process-env), against a --target of production (the default) or staging. Never silently combine environment sources or search parent dotenv files. Reuse Core environment resolution and security validation; report obvious application-key mistakes separately from unobserved key generation/custody. Config validity cannot prove mounted middleware, webhook verification, proxy trust, shared state, TLS, backups or deployed behavior.

Emit deterministic text/JSON with fixed check codes, source categories, actionable messages and explicit uninspected controls. Do not print values, input paths, parser details or secret-derived hashes. Reject malformed, duplicate, oversized, linked/special-file and unsupported interpolated inputs without executing them. No subprocess, network access, configuration/global-environment mutation or automatic repair belongs in this command. Test the installed command with real generated configuration and negative inputs; retain full hosted acceptance.

v13 supervision observation extension

The owner explicitly requested the reusable proctoring base on September 20. Extend rullst-supervision, not a second proctoring crate. Keep legacy visibility APIs working, but new sessions must persist an exact selected collection set and require matching acknowledgement at start/resume. Default selection remains page visibility. New browser event kinds are focus, copy/cut/paste occurrence and fullscreen transitions, never clipboard content or other-window inventories. Optional camera/microphone/screen capture-status reports are distinct from optional camera-presence/audio-activity adapter observations; preserve provenance and inconclusive/unavailable outcomes without a misconduct score.

Add bounded, typed, statically dispatched analysis interfaces. Permission and active session/tenant/subject/revision must be checked before analysis and again before storing its result. Keep borrowed media samples bounded and out of durable event state/logs. No camera/audio model, identity inference, hidden recording, grading or automated accusation is introduced. Selected capabilities do not establish browser permission or prove media belongs to the learner; the host must bind capture to the authenticated session and implement adapter cancellation.

The SQLite candidate moved to schema v2 before its first publication, with explicit rejection of old schemas. Never silently rewrite a v1 database: preserve it for review and require a fresh independently named store/epoch and authority provisioning for this prepublication transition. Session pause/end/expiry and policy revisions must reject pending results. Retain all existing capacity, sequence, clock, reviewer authorization and retention invariants.

Expose new browser collection only by explicit application selection (the removed make:supervision generator selected it by CLI option) and a matching visible notice. The collector must bound its queue and stop on session control, expiry or failure; it must not prevent copying, request fullscreen/capture or start media analysis automatically. The crate’s SQLite tests cover local adapter faults, disallowed capabilities, persistence, source attribution and legacy visibility compatibility; the extension passed hosted admission in PR #226.

Conditional v13 supervision crate

The owner requested transparent learner/exam supervision and parental controls as the first additional priority after the required v13 deliveries have been implemented and validated. rullst-supervision is now an implementation candidate, first published in the 13.0.0-alpha.1 prerelease, with bounded domain contracts and shared-local SQLite state. Its local generated-consumer and Chromium journeys passed before v13 removed that generated LMS consumer (make:supervision). The baseline also passed hosted checks and archive rehearsal in PR #222; the observation extension passed PR #226 and its retrospective archive check. The combined source passed PR #239. The package now enters the v13 release inventory with no default facade dependency. It was first published in the 13.0.0-alpha.1 prerelease on October 11, 2026; final release acceptance remains outstanding.

Keep supervision policy/session/event and reviewer-access contracts separate from rullst-privacy age and consent primitives. Exam supervision and parental controls need distinct modules and authorization policies. The initial candidate had to deliver one real generated LMS journey (retired in v13 with the complete Academy scaffold; applications now integrate the crate directly), visible session status, explicit permissions, revocation, bounded collection/retention and actual application-side enforcement, with cross-school/subject and unauthorized-reviewer negatives. Parental time/content restrictions initially concern this application only. The host must establish guardian/reviewer authority independently; age results, account ownership, a checkbox or a claimed family relationship do not prove it.

Browser observations are untrusted client reports, never proof of misconduct, identity or an automatic reason to change grades or impose a penalty. Collect only the documented minimal events with visible active/paused/ended state; do not add covert camera/microphone/location capture or unrelated browsing data. Native device-wide controls, camera inference and managed operating-system agents need separate platform integration and acceptance. Global legal compliance is not inferred from these controls. The exact API/storage boundary and its threat model must be specified before scaffolding the crate. Empty contracts or a mock alone do not meet its admission criteria, and it must not delay required release gates; retain it for a subsequent v13 release if capacity is insufficient.

The initial implementation contract is now the bounded supervision design. Use a separate package with optional exam, parental, sqlite and analysis features, no Core/default dependency, and a concrete SQLite adapter generic over a trusted clock. Tests use real local SQLite and an injected deterministic clock; no memory mock is needed for this initial backend. One private initialized database owns scoped authority, sessions, parental enrollment/policy and allowlisted events. Serialize operations with BEGIN IMMEDIATE, persist configuration, a clock high-water mark and a global revision counter, and enforce bounded quotas and retention. One learner has bounded retained-session and unexpired-event quotas inside the store-wide limits, and rows already past retention never block admission. An opener must supply the independently retained deployment epoch. This detects epoch mismatch, not restoration of an old database with the same epoch.

v13 removed make:supervision together with the complete Academy LMS it targeted. Applications depend on the crate directly with an exact version and default features disabled; the removed generator remains in the git history.

Only trusted operator provisioning can establish expiring ExamReview or ParentalManage authority after independent relationship verification. The host binds current authenticated identity, school membership and resource access; the store rechecks its own scoped grants atomically on each operation. Those two authorization layers are not one transaction unless the host composes them that way. In-flight results cannot be recalled after external membership changes. Session start/resume requires explicit current policy/notice acknowledgement; pause/end and policy changes use exact revisions. Events are only visible/hidden page reports with an exact next client sequence and server receipt time. Their absence or contents never influence grading. Parental enrollment is operator managed: an enrolled subject with absent, expired or denied policy is denied; an unenrolled subject retains existing application authorization. Revoking a manager does not remove the subject’s restrictions. The integrating application must enforce them on its original lesson/progress routes; the removed generated opt-in did so for the Academy scaffold.

Versioned release-branch boundary

Release policy schema 4 binds major 13 to development main and major 12 to maintenance v12. Schemas 2 and 3 retain their historical bindings; no existing tag or receipt is reinterpreted. The former transitional v13 integration branch (schema 3) was promoted to main and retired. The source-admission gate requires a canonical matching tag, the exact checked-out/tagged commit at the current protected branch head, and every publishable inventory package at the tagged version before artifact builds. All declared automatic release workflows accept main and v12; their triggers still list the retired v13 ref. Required manual/native/security evidence and the protected crates.io approval remain separate mandatory gates. Archive evidence requires the named archive job to succeed on the exact candidate; a green workflow with that job skipped is insufficient. Incompatible manual package selectors must fail explicitly. Observational CI reports may describe failed checks, but must stop on cancellation so an obsolete run cannot retain the concurrency slot needed by its replacement. Fuzz evidence must come from the candidate’s release line and a source carrying that same policy; v12 results cannot be credited to v13 merely because they are recent. The existing immutable v12 tags retain their original workflow and policy.

make_login_cookie issues a stateless rullst_session cookie: a v1. AES-256-GCM token bound to APP_KEY that holds the user id and an expiry 30 days ahead. The server keeps no session list. make_logout_cookie only asks the browser to delete its copy, so a copied cookie stays valid until it expires or APP_KEY changes, which signs out every user, and a password change does not end other sessions. v13 keeps the format, lifetime and validity of these cookies.

Revocation is opt-in and additive. make_login_cookie_with_epoch(user_id, epoch, max_age) and encrypt_session_with_epoch issue v2. tokens whose payload user_id|epoch|expires_at is authenticated with its own associated data, so neither version can be relabelled as the other. max_age counts whole seconds from 1 second to 400 days and sets both the token expiry and the cookie Max-Age. decrypt_session_claims returns SessionClaims for v2. tokens and, with epoch 0, for v1. tokens; tokens over 4096 bytes, unversioned, tampered, wrongly keyed, malformed or expired tokens fail, and a token remains valid through its expires_at second. SessionClaims::verify_epoch compares the token’s epoch with the stored value in constant time and returns AuthError::SessionRevoked on a mismatch. decrypt_session rejects v2. tokens, so a path that still uses it fails closed instead of skipping the epoch check. rullst-auth has no session middleware: the application stores the epoch per user, checks it on every request and increments it atomically for sign-out everywhere, a password change or reset, and account deactivation.

For an application that keeps its own users table, v13 adds single-use password-reset token primitives. PasswordResetToken::generate draws 32 bytes from the operating system’s CSPRNG and encodes them as 64 lowercase hex characters (which the mail pipeline’s secret redaction leaves intact in a token= link); Debug redacts it and its memory is zeroed on drop. Only digest(), a domain-separated SHA-256 (rullst.auth.password-reset.v1) in lowercase hex, is stored. password_reset_token_digest returns None for any submitted value that is not 64 lowercase hex characters, so malformed input never reaches a lookup, and password_reset_digests_match compares a stored digest in constant time and never matches a stored value that is not a 64-character digest. Expiry, single use and one live token per account are rules of the application’s store.

The cargo rullst auth and SaaS scaffolds (and the LMS example) store users.session_epoch (BIGINT NOT NULL DEFAULT 0, excluded from ORM writes with #[orm(db_default)] so a save cannot undo a bump), issue epoch cookies at sign-in and registration, refuse a cookie with another epoch in auth_middleware, and add POST /logout/everywhere, which increments the epoch in one UPDATE and clears the browser cookie. Plain logout still clears only the current browser. Revoking one device and listing sessions require server-side sessions (SqlRecoveryStore).

The same scaffolds generate a password change and a password reset by email (src/controllers/password_controller.rs, src/models/password_reset.rs and a password_reset_tokens migration) and enable the mail feature. Every submission is behind CSRF and the per-client credential_rate_limit, and every new password follows the registration policy (12 characters to 72 bytes) and must match its confirmation.

  • GET/POST /password/change, behind auth_middleware, verifies the current password with Argon2id on the bounded blocking pool (an account without a password hash never passes) and answers a wrong one with the generic “The current password is incorrect”. User::change_password stores the new hash and increments the epoch in one UPDATE that applies only while the account is still at the epoch of the asking cookie; that browser receives a cookie with the new epoch, so only it stays signed in, and the account’s reset token is deleted, so a link requested before the change no longer works. A cookie revoked meanwhile changes nothing and is sent to /login.
  • POST /password/forgot answers with the same page, after at least 400 ms, whether or not the email has an account. For an account it issues a PasswordResetToken and stores only its digest with Unix-second expiry and creation times in password_reset_tokens, whose primary key is user_id: one live token per account, replaced by a new request, except that a request less than 60 seconds after the previous one for the same account issues and sends nothing. Tokens expire after RESET_TOKEN_LIFETIME_MINUTES of src/models/password_reset.rs (30 minutes as generated), which a compile-time assertion bounds to 10 minutes through 24 hours; the stored expiry, the reset email (“This link expires in 30 minutes.”, or “2 hours” for whole hours) and the forgot-password answer all state that one value (from 13.0.0-alpha.3; alpha.2 hard-coded “30 minutes” in the answer and AccountMail refused more than 30). The link is RULLST_PUBLIC_ORIGIN/password/reset?token=… (process environment, then .env), validated by ActionLink and never derived from the request; when the origin is unset, development and test use http://localhost:3000, and staging and production log an error and send nothing. AccountMail delivers it through the Mail facade in a background task, so delivery never delays the answer. In a debug build in development without MAIL_DRIVER, where the default log driver records only metadata, the link is also printed to standard output.
  • GET /password/reset?token=… shows the form only for a live token, with Cache-Control: no-store and Referrer-Policy: no-referrer. POST /password/reset looks the digest up before any Argon2 work, then, in one transaction, deletes the token only while it is still live, stores the new hash and increments the epoch; a malformed, unknown, expired, used or replaced token changes nothing. A completed reset signs nobody in: it redirects to /login?reset=done, which shows a notice.
  • A completed change or reset emails a “password changed” notice. Deleting an account leaves its reset token row, which no longer redeems.
  • Account emails are written in the language of the request that caused them: the rullst::i18n::Locale that LOCALES.layer() negotiated when the router has one, else the Accept-Language header, each matched by language to the MailLocale set (English, Brazilian Portuguese, Spanish), else the editable DEFAULT_MAIL_LOCALE (English). The product name in their subject and heading is APP_NAME (process environment, then .env) or the editable APPLICATION_NAME constant, whose generated value is the package name.

cargo rullst make:oauth google|github|microsoft|apple (v13, from 13.0.0-alpha.3) adds sign-in with that provider to the same account model without tower-sessions; each provider is one run, and the first creates the shared flow, the oauth_identities table and its only migration. GET /auth/<provider> and the callback sit behind CSRF and the password login’s credential_rate_limit; the login challenge (state, PKCE verifier, OIDC nonce, provider, same-site return path) is stored with rullst_connect::challenge in rullst_connect_login_challenges of the application database, keyed by a digest of an opaque 256-bit id that is the only value in the rullst_oauth_challenge cookie (Path=/auth/<provider>, ten minutes, HttpOnly, SameSite=Lax, Secure in staging and production and on HTTPS). The callback consumes the challenge atomically before validating state, PKCE and, for OpenID Connect, the ID token and its nonce. A provider subject already in oauth_identities (provider and subject unique, user id cascading) signs in. Otherwise only an email the provider verified is used: an unknown one creates a password-less account linked to the subject, and one that already belongs to an account (including one that signed in with another provider) is refused unless the application switches EXISTING_ACCOUNT_POLICY to LinkAndResetPassword, which links it, removes the password and increments the session epoch in one transaction. Subjects and verified emails are: Google, the ID token sub and email_verified; GitHub (OAuth 2.0), the numeric account id and the primary address of GET /user/emails marked verified; Microsoft (MicrosoftOidcProvider with the required MICROSOFT_TENANT of common, organizations, consumers or a directory ID), <tid>:<oid> and the email claim only with xms_edov: true; Apple, the ID token sub and email_verified. Apple answers with a cross-site form POST, so its challenge cookie is SameSite=None; Secure, its callback also accepts POST and is listed in csrf_signed_webhook_paths (the challenge’s state replaces the CSRF token), its client secret is a five-minute ES256 JWT signed with the .p8 key from APPLE_PRIVATE_KEY (checked at startup, never written to a file), and the unsigned first-login name only names a new account. Sign-in uses make_login_cookie_with_epoch with the account’s current epoch, refusing an account the password login refuses. Unset credentials turn a provider off; an incomplete or mock configuration without the oauth-mock application feature, mock credentials in staging or production, and a missing callback origin there stop startup.

v13 active-session management (source admitted, prerelease)

Extend the existing opt-in SqlRecoveryStore rather than introducing another authentication registry or crate. A currently valid opaque session may list only its account’s at-most-20 active sessions, revoke a selected sibling or revoke all siblings. Management identifiers are purpose-separated keyed digests, never bearer credentials. Current-session logout keeps the existing revocation API. Every protected request still verifies authoritative SQL state; encrypted-only cookies and independent JWTs do not acquire revocation implicitly.

Inventory may expose creation/expiry times, a current-session marker and an explicit bounded display label. Do not collect IP addresses, raw user agents, device fingerprints or activity history. Old sessions without the additive metadata keep working and report unknown creation/label values. Metadata follows expired-session retention, logout and password-reset deletion in the same transactions. An operator-owned task purges at most 100 expired sessions per transaction; time passing alone does not physically delete rows. Session operations have a ten-second database deadline; an error does not prove an uncertain write rolled back, and applications reconcile authoritative state before reporting success. Management requires current authentication inside its SQL transaction; selective revocation binds the target to that account and rejects the current session. Logout of siblings also advances the account session version while preserving the current session, fencing previously issued password-authentication proofs.

The application supplies trusted server time and mounts management behind its authenticated tenant selection, CSRF, security headers, WAF and ingress limits. Account stores remain explicitly separated by application/tenant database and keys as documented in the recovery contract; request data cannot choose either. No device label proves device identity. Already-authorized in-flight operations, WebSockets and sessions managed by another identity system need separate policy. Required acceptance includes SQLite and PostgreSQL transactions, cross-account and cross-store denial, independent-process request verification, restart, expiry, password recovery, concurrent revocation and fail-closed storage errors. Source/package admission passed in PR #236; final release admission remains separate.

v13 email-login implementation contract

The owner-selected email-login increment passed combined hosted source/package admission in PR #239; final release admission remains separate. See the operational contract. It must stay explicitly optional in Auth, with SQLite shared-local and PostgreSQL shared state profiles composed with the existing authoritative recovery account/session registry. It must not turn a password-reset token into a login credential or silently enable email login for every existing account.

The selected journey requires an explicit server-configured application/tenant namespace and account opt-in established through existing authenticated account proof plus the host’s tenant/MFA policy. The application resolves membership and ordinary authorization; namespace strings from URLs are never authority. Email access alone does not satisfy a stronger authentication requirement.

Issuance generates separate random 256-bit emailed and browser-held secrets, binds their digests to the account, namespace and current authentication epoch, and queues a minimized encrypted notice atomically. Known, unknown, disabled and throttled accounts receive the same public acknowledgement. Storage, account and request limits must remain bounded. Tokens expire after a fixed 15-minute lifetime and are invalidated by replacement, account-policy changes or a changed authentication epoch. Database time observations and post-storage expiry checks must prevent rollback or long lock waits from authenticating stale evidence.

GET/HEAD requests may render the landing page but never consume a token or mint a session. Redemption requires deliberate CSRF-protected POST, the matching browser-held secret and the current configured context. Token consumption and creation of the existing revocable opaque session must commit in one database transaction. Any uncertain outcome returns no authentication credential. The session retains existing limits, inventory, logout and epoch revocation.

Landing and destination URLs are explicit server configuration, with HTTPS in production, no request-derived host/redirect and no third-party page content. Responses require no-store and no-referrer protections; secret-bearing URLs, cookies and messages must not enter logs. A fenced encrypted delivery outbox supports bounded retries and stable delivery identifiers, with a dedicated Mail security template and deterministic delivery fixtures. An email scanner’s GET must not invalidate the user’s login. These token and prefetch choices draw on OWASP’s URL-token guidance and Supabase’s documented prefetch behavior.

Required acceptance includes both relational backends, concurrent redemption, account/namespace/browser mismatch, account-policy and password-reset races, outbox failure/retry, session limits, server restart, a real HTTP/browser journey and extracted-package consumers. Production credentials and live inbox delivery remain outside the owner’s authorized testing scope. The existing reset journey keeps its separate behavior and must retain its regression evidence.

v13 application API-token implementation contract

The third owner-selected increment is a separate optional Auth lifecycle for opaque application API tokens, with SQLite shared-local and PostgreSQL shared state. It must compose the authoritative recovery account epoch and private database connection safeguards without enabling email-login, JWT or OAuth by default. Provider credentials and browser-session tokens are separate purposes.

Issuance requires a current password-authentication proof from the same account registry plus the host’s tenant/MFA/permission approval. An immutable server namespace and literal scope allowlist bound every token. Requested scopes are nonempty, bounded and exact (no wildcards); the application intersects its current account permissions before issuance and checks current tenant/resource authorization on every domain request. A client-supplied namespace or subject never establishes authority. API tokens cannot mint other tokens through this management API.

Return a random 256-bit secret once, with a separate opaque management ID and an unmistakable versioned prefix; persist only a purpose/namespace-bound HMAC. Store bounded labels, literal scopes, expiry and revision, without IP, user-agent or usage history. Enforce a per-namespace row quota, at most 20 active tokens per account, and a configured maximum lifetime no greater than 30 days. Verification must read authoritative state, validate the account epoch, exact required scope subset and trusted time, and fail on missing/unavailable/revoked state. Never cache a positive verification as a replacement for these checks.

Owner-only inventory, idempotent revocation and atomic compare-and-swap rotation are required. Rotation keeps the management ID and scopes, advances the revision, replaces the secret and may renew only within the configured lifetime. A stale rotation cannot overwrite a newer credential or revive a revoked token. Deleting a token is authoritative revocation: caller-selected IDs cannot recreate it. Account epoch changes invalidate all older tokens. Concurrent checks have a documented database linearization point; already authorized external work cannot be recalled or made atomic with a later revocation.

Reuse verified remote TLS, bounded private pools/SQL deadlines, durable storage checks, namespace configuration binding and persisted clock observations. An uncertain issuance/rotation returns no credential, and restart, expiry during lock waits, rollback, foreign-account management, scope escalation, token-purpose confusion and concurrent rotation/revocation need executable evidence. Document backup anti-rollback and host-owned authorization boundaries. Full workspace, native protocol/process, authenticated HTTP and extracted-package admission are required before a supported feature claim; no external provider accounts are needed or authorized.

HTTP composition adds an explicit Core MachineEndpoint::verified_bearer constructor using the existing exact-route MachineRequestVerifier contract. Auth supplies a verifier that requires one Authorization bearer header, reads current token state and inserts a redacted principal. It never accepts cookies, origin-bearing browser requests or URL tokens as machine authentication. The existing static bearer constructor retains its behavior; only an authenticated exact method/path receives the machine CSRF exception, with WAF/headers retained.

v13 shared PostgreSQL mail-suppression implementation contract

The fourth owner-selected increment extends Mail’s existing suppression traits with an optional PostgreSQL store and facade feature. It must preserve verified event replay binding, conflict rejection, monotonic reason precedence, immutable recipient/event quotas and final pre-dispatch checks through SuppressionGuard. It is not a new transport and must not change default Mail/SQLite features.

Each instance selects an immutable server-owned namespace, quotas and a strong secret HMAC key. Persist purpose/namespace-bound keyed recipient/event identifiers and event fingerprints, not raw recipient addresses, provider event IDs, message bodies or delivery history. Keep only the authoritative reason/provider and first/last observation times needed by the existing record contract. Configuration and key drift fail closed. A namespace chosen in a request is never authority; the host authenticates tenant membership and verified provider events before selecting the corresponding store/driver. Memory remains the explicit offline test implementation; production database failures cannot silently use it.

Provide explicit deployment initialization and ordinary startup without DDL or missing-state repair. Runtime operates without schema mutation privileges, on one authoritative writable PostgreSQL database with verified remote TLS, permanent tables, durable commit settings, bounded pools/waits and whole-operation deadlines. Serialize event application, lookup and retention through the namespace control row. Atomically record replay evidence and recipient state; cancellation, conflicting events or quota failures must not leave partial state. Recheck persisted server time and storage durability before reporting outcomes. The persisted time is a monotonic high-water mark: a host clock at most 300 seconds behind it is cross-host skew and adopts the recorded time; a larger regression fails closed.

Retention may prune replay identifiers after the host-selected provider replay window, but never remove recipient suppression as a side effect. Lookup/storage faults block delivery. Check immediately before dispatch, while documenting that a suppression committed after that check cannot recall in-flight external mail. This does not promise exactly-once provider delivery, inbox acceptance, backup anti-rollback, automatic failover, legal compliance or automatic opt-in after a complaint. Required evidence includes independent pools/processes, concurrent replay/conflicts/quotas, rollback/cancellation, restricted roles, service restart, real guard/worker composition and extracted-package consumers.

v13 durable recurring-publication implementation contract

The fifth owner-selected increment belongs to the existing Messaging crate as optional schedules-postgres, with an explicit facade feature. It coordinates bounded UTC cron occurrences across instances through one authoritative PostgreSQL database and relays them to the existing static MessageBroker contract. Core’s process-local Scheduler retains its API; it evaluates POSIX five-field expressions in UTC (weekday 0/7=Sun, either restricted day field matches), or in a named IANA zone through the opt-in scheduler-tz task_in_tz (section 4.3), unlike the projection below. A broker publication is not proof of handler completion or an exactly-once external effect.

Require a server-owned namespace, immutable limits and explicit encrypted storage keys. Schedule names are bounded application configuration, not tenant authority. The host authenticates authoring/inspection/cancellation and selects the correct namespace and broker. Creation is idempotent for the same immutable definition; conflicting reuse fails. An edit requires cancellation and a new schedule name, with immutable generation identities protecting retained occurrence keys.

Use the cron crate’s five-field projection in UTC (weekday 1=Sun through 7=Sat, calendar-day and weekday restrictions intersect; names are accepted), with a one-minute minimum resolution, bounded definitions/payloads, explicit first-after time and explicit missed-run policy. Bounded catch-up emits the oldest due occurrences within the caller’s tick budget; coalescing emits one oldest outstanding occurrence and advances after the observed current time. Advancing the schedule and persisting frozen occurrence content must be atomic. Storage quota exhaustion cannot silently discard due occurrences.

Persist definitions and occurrence content using authenticated encryption bound to namespace, schedule generation and occurrence identity. Do not silently adopt plaintext storage. Lease ownership, attempts, retry deadlines and terminal state are authoritative SQL data. Leases are unpredictable and exactly fenced; stale workers cannot acknowledge/retry/revive a cancelled or superseded occurrence. Broker requests use deterministic purpose-separated occurrence idempotency keys, and retries preserve exact content. Recheck a live lease immediately before publication and acknowledge only after successful broker acceptance. If acceptance precedes an uncertain ACK, retain evidence of acceptance and reconcile/retry the same key. A cancellation after the final check cannot recall an in-flight broker request; consumers must deduplicate/authorize at their side-effect boundary.

Expose bounded inventory/terminal inspection, explicit failed-occurrence retry, permanent cancellation and retention of terminal rows only. Automatic attempts and the delivery window are bounded. Deadlines, clock rollback beyond a five-second cross-instance skew tolerance (a trailing instance adopts the monotonic recorded namespace time), namespace/key drift and non-durable/unavailable state fail closed. Reuse verified TLS, bounded pools/SQL deadlines, explicit initialization and runtime without DDL privileges. Document UTC-only behavior, unchanged per-process cron, broker deduplication retention, backup/failover obligations and host-owned authorization. Required acceptance includes independent instances/processes, real PostgreSQL rollback, restart and restricted roles, cancellation/lease races, durable broker consumption, publication-before-ACK replay and extracted-package/facade consumers.

v13 durable outgoing-webhook implementation contract

The sixth owner-selected increment extends Messaging behind opt-in webhooks and facade messaging-webhooks. Compose the existing encrypted SQLite broker as a private durable outbox rather than adding a second queue. The first profile supports independent processes sharing one local database file; remote/multi-host webhook state remains separate. Do not change default dependencies. App-domain SQL transactions and this outbox are distinct; applications needing atomic domain publication must bridge from their existing transactional outbox.

One server-approved immutable destination, signing identity, delivery window and bounded policy belong to each server-owned namespace. Persist an encrypted control publication with a fixed idempotency key: configuration/secret drift must conflict, not redirect queued data. Never expose this private broker or purge/subscribe to its control topic. Freeze bounded JSON bytes and event kind; retries keep the stable message ID and exact body. The host authorizes publishing, inspection, cancellation and manual retry before selecting the namespace.

Production accepts only explicitly configured HTTPS destinations, with verified TLS, no proxies/redirects, bounded timeouts and fresh public-address resolution pinned to the actual connection. Deny private, loopback, link-local, multicast, reserved, documentation and transition address ranges, including mixed DNS answers. An explicit literal-loopback development mode and deterministic offline mode may support owned fixtures; production configuration rejects both. Empty or mock signing credentials select offline delivery, never a network fallback.

Sign exact body bytes, stable ID, kind, key ID and attempt timestamp with a purpose-separated HMAC-SHA256 contract. Provide strict constant-time receiver verification with bounded time skew; receivers must also store processed IDs and authorize the event. Do not claim signatures prevent replay by themselves. Keep signing/storage keys separate and redact credentials, URL, payload and response bodies from Debug/errors/terminal inspection.

Use bounded attempts/backoff and an immutable delivery window. Revalidate the actual SQL lease immediately before external dispatch, after DNS work. A late worker cannot acknowledge/retry/revive another worker’s claim or cancelled work. Acceptance before an uncertain ACK remains an at-least-once condition; preserve the accepted HTTP status/ID and retry the same identity. Retry transient failures, terminally reject redirects and permanent failures, and expose bounded minimized terminal inspection, explicit retry within the original window, cancellation and terminal retention. Retention must not delete active work or control state. The host owns disciplined trusted time, persistent disk/backup policy, receiver deduplication, endpoint approval and worker supervision. Native acceptance must include actual HTTP/TLS protocol fixtures, SSRF/redirect/signature/replay negatives, independent instances/process restart, SQL cancellation/outage/lease contention, retry/dead-letter/retention and extracted-package/facade consumers. Real provider accounts are not required or implied.

v13 SQLite messaging lock-time correction

Before composing outgoing webhooks, real lock-contention tests demonstrated that SQLite broker operations sampled time before BEGIN IMMEDIATE: an ACK could be accepted after its lease expired while waiting, and a new claim could already be expired on acquisition. Publication, claim, ACK, retry and dead-letter operations must sample trusted time after acquiring their write transaction. Retry availability and new lease duration start from that admitted instant. Expired workers fail with LeaseExpired; their work remains recoverable. The process-local InMemoryBroker (also the Redis mock backend) likewise samples time after acquiring its state lock. This correction preserves public API/schema and does not add persistent clock anti-rollback or change at-least-once delivery semantics.

v13 shared-local SQLite bootstrap connections

The Labs, Media and Supervision stores and the Privacy consent and age-replay SQLite stores bound every wait for a pooled connection at 5 seconds, while statements and commits wait for the disk. Establishing a connection (opening the file and its WAL, and in initialize the WAL conversion with its full syncs) runs inside that bound. Loaded hosted Windows runners stall a process’s file I/O for 5 to 12 seconds at times, so initialize failed there with Storage or Busy and left a new file that a second initialize refuses. Since 13.0.0-alpha.3, the steps of initialize and open (for the age-replay store, open, which also creates a missing file) are tried again when their connection times out, three attempts in total (about 15 seconds): no other task can use the new pool yet, so the timeout means a slow establishment, not contention. Every other operation keeps the single 5-second bound and its Busy or Storage result. Labs keeps the SQLite result code, I/O error kind or pool condition behind the last Storage error of a thread for its test fixtures, never a message, SQL text or path.

v12 audit correction invariants

12.1 account mail: rullst-mail::ActionLink validates an exact application-configured origin (HTTPS, or explicit loopback HTTP); action URLs and Message contents are redacted from Debug. The compatible Message pipeline preserves only bounded opaque token query values inside safe body URLs, including HTML-escaped query separators. This context-aware exception does not apply to subjects, arbitrary non-URL text, URL credentials, other secret parameters, fragments, provider errors or telemetry. Applications own the destination authorization; a generic body URL is not an identity proof. AccountMail builds versioned deterministic English, Portuguese and Spanish account notices. A link’s remaining lifetime is stated as 1–1440 minutes (24 hours) for PasswordReset and 1–30 minutes for the verification and email-change links, in minutes or, when whole, hours (“1 hour”, “24 hours”); anything else is a validation error. Reset issuance expires after 20 minutes; delivery uses an absolute UTC expiry so a stable idempotency key retains identical content. Templates generate neither action tokens nor marketing consent and add no tracking. Provider tracking configuration remains a separate obligation. LogDriver records only delivery metadata.

Opt-in Auth recovery-postgres/recovery-sqlite owns an authoritative account registry, purpose-separated keyed token digests, revocable opaque sessions and an AES-GCM transactional outbox. Account creation enqueues welcome; password replacement, reset consumption, sibling invalidation, session revocation and a password-change notice commit atomically. The facade account-mail-* features compose this store with Mail without another publishable crate. Existing password tables and encrypted-only cookies require explicit migration; no implicit application rewrite or cross-database transaction is promised. Bounded claims, retry, idempotency propagation and Resend/Svix feedback verification are additive foundations; the 12.1 ACS Managed Identity transport was removed in 13.0. Redis recovery, other identity-action transactions, marketing consent and audited administrative resend are not implemented by this contract. See account mail for exact limits, deployment obligations and external acceptance boundaries.

12.1 additional Mail transports: native SendPulse static Bearer authentication preserves the mandatory pipeline and deterministic offline credentials. Real sends require an explicit verified sender, due message, bounded JSON and validated success body. SendPulse requires account-level tracking configuration and rejects inline CID/unsubscribe-header messages outside its reviewed REST contract. The local MailTrap harness is not the hosted Mailtrap service. Provider account acceptance and durable operation reconciliation remain external; no exactly-once promise is inferred from a delivery ID. HTTP failures omit provider bodies while retaining status and bounded retry metadata. The Mailjet and Mailtrap transports added in 12.1 were removed in 13.0.

v13 Mail provider scope: rullst-mail keeps Resend, AWS SES (native SigV4 behind aws-ses, or the offline fixture and explicit bearer proxy), SendPulse and SMTP, the log, memory and offline mock_* drivers, and the shared pipeline, failover, tenant resolver, attachment inspection, suppression, observation, feedback and queue support. The SendGrid, Postmark, Mailjet, Mailtrap and Azure Communication Services transports, their MAIL_DRIVER values and the settings only they read were removed; the code remains in Git history and the 12.x line. A MAIL_DRIVER or [mail] driver naming a removed transport must fail facade resolution with MailError::ConfigError naming the driver and the migration row, and must never fall back to log, memory or an offline mock. Queued rullst_mail_send jobs carry no provider name, so a 13 worker delivers 12.x jobs through its configured driver or fails them visibly. Suppression rows and observations keep free-form provider labels and stay readable. Applications implement other providers with MailDriver (Writing your own mail transport); the migration guide lists the removed names, and cargo rullst upgrade reports them as V13-MAIL-REMOVED.

12.1 one-time Stripe foundation: a separate fixed-price mode=payment contract checks active provider catalog amount/currency before creation and binds session, owner, customer, attempt, price and mode. Receipt reads validate the expanded PaymentIntent/charge, with partial refunds and disputes preventing Paid. Signed event hints must be reconciled through application-owned durable inbox/entitlement transactions. Existing generated billing remains recurring; new sandbox evidence and merchant refund/dispute operations are still required.

12.1 authenticated machine endpoints: an immutable MachineEndpointPolicy on Server exempts only registered write-method/path pairs from browser CSRF after proving a strong bearer secret or an explicit host-supplied signed-webhook/mTLS verifier. Cookie/Origin/Sec-Fetch-Site inputs are rejected on these routes; body size is bounded and WAF/secure headers remain composed. The development hot-reload server (Server::new_hot) authenticates the same routes in front of each reloaded router. A path segment after the first may be a {name} placeholder matching one non-empty segment other than . or .. (v13); two routes with the same method that one request path could match are refused. Wildcards and weak secrets fail at construction; a static bearer token needs 32–200 printable ASCII bytes with at least 8 distinct values. Applications own ingress limits, replay storage, certificate trust and authorization; an unverified proxy header is not mTLS.

The current release audit reopens earlier readiness claims. A historical score, checked roadmap item or green mainline run is not evidence that the current revision satisfies these contracts.

  • Core’s production COEP remains require-corp by default. Application configuration accepts only require-corp, credentialless, or unsafe-none; choosing a less isolated policy and the matching CSP/media allowlist is an explicit application threat-model decision. The html! parser accepts and strips source-only <!-- ... --> comments; comments do not create a raw-HTML trust boundary or client-visible output.
  • Generated safe ORM projections validate column identifiers. Empty membership predicates match nothing. Mandatory tenant, global and soft-delete scopes remain grouped outside application OR expressions; nested queries preserve validation errors. Bulk mutation must not bypass model authorization.
  • Queries inside a managed transaction use its executor. Callbacks that already borrow a mutation executor must fail explicitly on unsupported reentrant ORM access rather than deadlock or silently use an unrelated connection.
  • Local durable stores use SQLx-owned transactions so dropping a cancelled operation schedules rollback. A cancellation racing an already dispatched commit can still have an uncertain outcome and requires reconciliation.
  • Authentication validates expiry independently of clock-skew allowances; provider-specific identity claims, nonce and refresh semantics cannot be replaced by successful offline fixtures.
  • Payment adapters must not fabricate live checkout prices, portal URLs or mutation success. Unsupported provider operations fail explicitly. Signed webhook bytes still require provider-specific schema and lifecycle validation.
  • Development restart owns builds, application snapshots and migrations. It is not a stable Rust DLL ABI or production rolling-deployment mechanism. Windows descendant cleanup remains best effort without a Job Object implementation.

These describe required behavior, not a declaration that every final release gate has passed. The audit records the current evidence and remaining work.

Application-key maintenance invariant (12.1.1 and v13)

Application-key validation rejects public scaffold/documentation placeholders after trimming and case normalization. Legacy Rullst.toml discovery accepts only exact app_key or key field names, not fields sharing a prefix. It keeps the historical value before the next = so valid existing sessions remain readable; it does not rewrite configuration or silently rotate secrets. Prefer APP_KEY for new values containing =. Moving an existing TOML value must preserve its effective bytes or explicitly rotate the secret and invalidate sessions. Session operations and optional JWT signing-key construction reject the documented placeholders before accepting session/token processing. The maintenance correction is carried from v12 PR #259 and was published in 12.1.1; v13 release admission is separate from its presence in development source.

The corresponding facade fuzz harnesses exercise real configuration parsing, authenticated session decryption/tamper rejection, tenant membership resolution and tenant-scoped realtime publication, with deterministic positive/negative controls. The realtime target does not exercise TCP/WebSocket frame decoding. Historical runs of the replaced no-op/weak-fixture harnesses are not evidence for these contracts. v13 campaign admission remains outstanding; the port does not import the v12-specific source-equivalence policy.

Studio browser composition invariant (12.1.0)

Studio browser composition in the published 12.1.0 maintenance release preserves both root and /studio-nested same-origin asset routes. A raw browser without a supplied cache renders an explicit unavailable state and exposes no cache mutation endpoints. The full local builder installs the configured cache once and retains its verified-loopback/same-origin protection. Assets and navigation fixes do not constitute a shared-production authentication mode; existing application-level workaround routes must be removed before upgrading.

Mobile presentation invariant (12.1.0, carried forward to v13)

The published 12.1.0 mobile maintenance contract keeps Nexus navigation dismissible by close control, backdrop, Escape and links, with keyboard focus containment/return and visible no-JavaScript navigation. The Portfolio example (examples/portfolio, the former scaffold) must reflow at phone widths and wrap long content instead of hiding overflow. These are presentation fixes, not changes to server authorization; pre-existing generated source and showcase HTML-rewriting workarounds need explicit migration.

Pre-release scaffold source invariant

An unpublished pre-release cargo-rullst may reuse only local framework crates whose package names and versions exactly match that CLI. The invocation directory is preferred; otherwise the exact still-present checkout from which the CLI was compiled is used. Stable or version-mismatched packages fall back to crates.io. This permits evaluation outside the repository without silently mixing release trains, but generated absolute path dependencies remain non-portable until the matching immutable release is published.

Distributed operation tracing candidate (v13, source admitted)

The fifth approved September increment extends Core/facade’s optional telemetry capability, using the maintained OpenTelemetry SDK and OTLP/HTTP protobuf transport. The existing process-local SpanCollector and authenticated Studio ingestion remain separate interfaces. A new explicit configuration must select service and bounded operation labels, a trusted collector, bounded queue, batch and export deadlines, and an owned flush/shutdown lifecycle. It must not infer user identity or authorization from trace identifiers.

Context propagation accepts canonical nonzero W3C version-00 traceparent only after an explicit upstream trust decision. Untrusted ingress starts a new trace; arbitrary baggage and vendor tracestate are not propagated by this minimized profile. An actual independent producer/consumer journey must preserve parent relationships through the existing Messaging carrier and reach a disposable standard collector. Export only bounded approved operation/service names, trace/span relationships, timing, kind and status; omit application attributes, events, error descriptions, SQL, tokens, bodies and ambient resource metadata. This profile does not sanitize other tracing/logging layers.

Export must run outside request execution with a compatible SDK processor/client, bounded response handling and no redirects or ambient proxies. Production uses verified HTTPS; literal-loopback HTTP is an explicit test/local-collector choice. Remote failure never falls back to a fake success. Malformed propagation, queue pressure, unavailable/slow collectors, TLS denial, exact package consumption and flush/shutdown require executable evidence. Sampling, retention, collector access, durability, availability and deployment operations remain host responsibilities; trace delivery is best effort, not an audit ledger or application transaction. The implementation and actual collector/TLS/process evidence are documented in the tracing guide. Hosted source/package admission passed in PR #236; final release admission remains separate. Legacy initialization uses compatible threaded export and the corrected OTLP/HTTP endpoint; it retains its separate general logging/metadata policy.

Recoverable Live UI candidate (v13, source admitted)

The fourth approved September increment extends rullst-core::live with a separate opt-in typed recovery API, preserving the legacy component interface. An application-bound tenant/account/component scope and mandatory static-dispatch authorization callback must be checked before upgrading, before actions and outputs, and periodically while connected. Exact browser origin and protocol validation supplement, rather than replace, application authentication.

The v1 transport sends bounded complete server snapshots on initial connection, reconnection and conflicts. Commands carry a bounded correlation ID and expected revision; the host must check that revision atomically with its persistent domain write. The client must not queue or automatically replay mutations after uncertain disconnects. Recovery converges to authoritative application state, not replay of every missed transient event. Browser/server buffers, admitted connections, callbacks, sends, session lifetime and event counts must be bounded. Expired or revoked access closes the session without exposing a new snapshot.

A small optional same-origin browser module and real browser/server tests must prove reconnect convergence, action conflict, session/tenant denial, revocation, bounded malformed/slow peers and recovery after an application restart. Domain persistence, transactional revision checks, HTML escaping, authorization policy, replication and proxy configuration remain explicit application responsibilities. No automatic durability or upgrade of legacy LiveComponent code is implied. The legacy live_ws_handler bounds incoming WebSocket frames and messages to 64 KiB before JSON parsing; its origin, admission and idle policy stay with the application route. ws::WebSocket::recv skips Ping/Pong control frames, so client or proxy keepalives do not end a legacy Live session. A #[live_component] dispatches only on the first present rullst_event, action or event string field and runs at most one #[live_event] handler per message; other payload keys and values, including form inputs, never select a handler. A path-qualified marker such as #[rullst::live_event] marks a handler exactly like the bare attribute. The implementation and its local protocol/Chromium acceptance are recorded in the recovery guide. Hosted workspace/platform/package admission passed in PR #236; final release admission remains separate. The API uses complete snapshots; it does not imply DOM diffing, broadcast of other clients’ changes or automatic event replay.

Remote messaging candidate (v13, source admitted)

The first remote adapter is an opt-in Redis Streams profile in the existing rullst-messaging crate. Redis stores the canonical envelope log; Rullst owns bounded group, retry, dead-letter and single-use lease indexes. These are Rullst consumer groups, not interoperability with arbitrary native XREADGROUP clients. Every lease mutation must check authoritative server time and the exact current capability atomically. Publication replay must retain the original receipt until explicit terminal purge. Empty/mock credentials select an explicit process-local fixture; a failed live connection must never downgrade to that fixture.

The initial profile targets a dedicated standalone Redis 7.4+ database with verified TLS outside an explicit literal-loopback test mode. Exact configuration and an application-supplied deployment generation bind persisted namespaces; provisioning is explicit and normal connection must not recreate lost state. The TLS adapter preserves an installed Rustls process provider and otherwise installs aws-lc-rs, the framework’s Rustls provider, before Redis creates its client, avoiding implicit-provider panics when dependency features enable multiple providers. Applications requiring a different provider install it before broker startup; trust and hostname checks remain mandatory. Lua mutations are isolated but do not roll back on runtime errors: a persistent in-progress marker must quarantine partial changes rather than silently continue. Bounded batches, retained bytes, subscriptions, operation deadlines and admission limits are required. AOF/no-eviction, clock discipline, credentials/ACLs, backups, restore reconciliation, topic authorization and destination idempotency remain deployment responsibilities. Cluster, replication/failover, native-group mixing, automatic repair and exactly-once effects are excluded from this profile.

Admission requires the shared broker contract plus an actual disposable Redis restart, conflicting publication, concurrent consumer, expired/late ACK, retry, DLQ, outbox replay, configuration-loss and partial-write failure evidence. The implementation candidate and its capability/operational limits are described in the Redis messaging guide. Hosted source/package admission passed in PR #236; final release admission remains separate.

OAuth authorization request invariant

The built-in Connect authorization-code providers explicitly request response_type=code. With ordinary authorization endpoints, the plain URL, state, PKCE, and combined trait variants each contain exactly one response type and retain the configured client, redirect, scopes and encoded state/PKCE. Apple retains response_mode=form_post; offline mock redirects remain local fixtures rather than real authorization requests. The managed AuthSession extractor reads only the callback query string, so it does not complete Apple’s cross-site POST callback; an Apple host keeps its state, PKCE verifier and nonce behind a SameSite=None; Secure challenge cookie and validates the posted form itself. The v13 AuthSessionForm extractor consumes the same managed challenge from a POST application/x-www-form-urlencoded body of at most 16 KiB, rejecting another method, content type or oversized body before touching the challenge; the SameSite=None; Secure cookie requirement remains. Application configuration, nonce/callback validation and provider-account interoperability remain separate. The generic build_oauth_params helper and exported redirect macro remain response-type-neutral for downstream custom providers. Callers own optional endpoint query data and must not supply conflicting reserved parameters.

Client-supplied token invariant

Provider::get_user_from_token is a profile lookup for a token the server obtained for its own client. It is not an authentication primitive for a token supplied by a native or mobile client: except for Apple, the adapters ask the provider’s userinfo/profile endpoint about the bearer token, which does not prove that the token was issued to the configured client_id. Sign-in must use the application’s own authorization-code exchange or an ID token verified for this client’s issuer, audience, expiry, signature and nonce. Apple’s adapter validates an ID token’s audience there, but checks no nonce. In v13, GoogleProvider::verify_id_token and OidcProvider::verify_id_token are that audience-bound entry point: they require a non-empty expected nonce, reuse the code-exchange ID-token validation (JWKS signature, exact issuer, aud/azp equal to the client ID, except that Google’s verify_id_token also accepts an azp from its explicit bounded native-presenter list while aud stays exact, exp/iat, nonce) and never call userinfo.

Shared-local facade composition invariant

The umbrella features auth-sqlite, capital-quota-sql, oauth-sqlite, mail-sqlite, messaging-sqlite, and queue-sqlite may deliberately share one file-backed SQLite database for a bounded single-host deployment. Each subsystem owns a fixed, distinct table namespace. The host prepares and checks every store sequentially and keeps ApplicationLifecycle unready until all required components have succeeded. Restart must reuse the same validated database URL, quotas, namespaces, and encryption keys; all handles must close before a host copies, restores, or replaces the file.

The executable facade contract proves persistence and idempotent replay across restart, encrypted token/message plaintext absence from the database/WAL/SHM, queue recovery, aggregate readiness, and fail-closed token corruption without breaking an unrelated mail read. It does not create a cross-subsystem transaction, consistent online backup, key manager, distributed database, or multi-host coordination. Whole-file snapshot consistency, filesystem trust, permissions, key/backup operations, contention policy, and recovery drills are host responsibilities. Separate databases remain preferable where failure isolation or write throughput matters.

Native relational enum invariant

rullst-orm has one bounded native-enum contract. #[derive(Enum)] generates a closed label set shared by Display, parsing, Serde, RullstValue and SQLx codecs. A database enum has 1–64 unique labels; its type identifier and labels use the documented bounded ASCII allowlists. Blueprint::native_enum emits:

  • a named PostgreSQL enum with exact existing-label drift detection only under strict-postgres;
  • an inline ENUM for MySQL/MariaDB; and
  • a TEXT CHECK constraint for SQLite.

The PostgreSQL type is created as a quoted, case-preserving identifier, and the generated SQLx codec names it quoted as well, so a mixed-case type_name resolves to exactly that type. A manual sqlx::Type implementation for a mixed-case DatabaseEnum::TYPE_NAME must quote the name the same way.

PostgreSQL enum creation, its label drift check and Schema::drop_native_enum run through the task-scoped transaction when one is active, exactly like the table DDL: they roll back with it, and dropping the type after its tables in one transaction cannot block on that transaction’s own locks from a second pooled connection. Generated builder comparisons (=, !=, <, >, IN, BETWEEN, with their or_/not_ and where_<column> forms) on a persisted field whose type implements DatabaseEnum bind the value as CAST(? AS "<type_name>") on PostgreSQL, where a text parameter has no operator against a named enum; the other backends keep ?. The live PostgreSQL matrix filters such columns. PostgreSQL through SQLx Any must fail before DDL because that driver cannot decode custom PostgreSQL types. Adding, removing or reordering variants, deployment order, dependent-object removal and rollback remain explicit, reviewed migration work. The schema helper does not auto-migrate an existing type or infer application compatibility.

Blueprint::timestamps() adds nullable created_at/updated_at columns that default to the database’s current time. With a strict driver feature they are timestamp_tz() columns (below), read as chrono::DateTime<Utc>. The SQLx Any pool cannot read date/time values, so there they stay TEXT columns for String fields: SQLite and PostgreSQL receive DEFAULT CURRENT_TIMESTAMP, and MySQL/MariaDB, which accept a default on TEXT, BLOB, JSON or GEOMETRY columns only as an expression, receive DEFAULT (CURRENT_TIMESTAMP); the builder parenthesizes every other non-NULL default on those column types (MySQL 8.0.13+, MariaDB 10.2.1+). The strict-pool columns are a v13 change; alpha.1 created TEXT on every pool.

Schema constraints, indexes and date/time columns

The Blueprint declares constraints and indexes next to the columns, and every table, column, constraint and index name passes the identifier grammar (validate_table_name); constraint and index names are at most 63 bytes so PostgreSQL never truncates them.

  • Column::unique() emits a column-level UNIQUE. unique(&[cols]) emits CONSTRAINT <table>_<cols>_unique UNIQUE (...) inside CREATE TABLE.
  • index(&[cols]) and unique_index(&[cols]) (<table>_<cols>_index / _unique) run as CREATE [UNIQUE] INDEX IF NOT EXISTS after CREATE TABLE on PostgreSQL and SQLite and are INDEX clauses of CREATE TABLE on MySQL/MariaDB, which has no IF NOT EXISTS for indexes.
  • foreign(col).references(table, col) emits CONSTRAINT <table>_<col>_foreign FOREIGN KEY ... REFERENCES with the quoted, PostgreSQL-folded referenced table and optional ON DELETE/ON UPDATE CASCADE, RESTRICT, SET NULL or NO ACTION; foreign_id(col, table) also adds the INTEGER NOT NULL column and references table(id). SET NULL on a NOT NULL column fails the build.
  • check(expr) emits CHECK (expr). The expression is trusted developer SQL: the build rejects an empty or longer than 512-byte expression and one with ;, --, /*, */, \, $, a backtick, a control character, unbalanced parentheses or an unterminated quote, so it cannot leave its clause; it is never built from request data. MySQL enforces CHECK from 8.0.16, MariaDB from 10.2.1.
  • On MySQL/MariaDB a key (column UNIQUE, unique constraint, index or foreign key) on a column whose type is TEXT, BLOB or JSON fails the build: those servers index such columns only by a prefix, which would make a unique key compare only the prefix.

Schema::create validates every statement before running the first. Without a table name, Blueprint::build() leaves the default constraint names to the database and omits the separate PostgreSQL/SQLite index statements.

Schema::table alters an existing table: ALTER TABLE ... ADD COLUMN per column, then ADD CONSTRAINT ... UNIQUE, CREATE [UNIQUE] INDEX, ADD CONSTRAINT ... FOREIGN KEY and ADD CHECK, all validated first and run in order in the task-scoped transaction when one is active (MySQL/MariaDB commit each DDL statement). SQLite cannot add a constraint to an existing table: a unique constraint or unique() column becomes the equivalent CREATE UNIQUE INDEX, and a foreign key on a column added in the same call becomes that column’s REFERENCES clause (the column must then be nullable without a non-NULL default). A SQLite foreign key on an existing column, a second foreign key on the same new column (one REFERENCES clause fits), a CHECK, a primary-key column, a NOT NULL column without a default and a current-time default return Error::UnsupportedSchemaChange { driver, change } before any statement runs.

date(), time(), timestamp() and timestamp_tz() are columns for chrono::NaiveDate, NaiveTime, NaiveDateTime and DateTime<Utc> fields (rullst_orm::chrono): DATE, TIME, TIMESTAMP and TIMESTAMPTZ on PostgreSQL; DATE, TIME(6) and DATETIME(6) (a UTC instant for timestamp_tz, because TIMESTAMP ends in 2038, with SQLx setting the session time zone to +00:00) on MySQL/MariaDB; and ISO-8601 TEXT on SQLite in the forms SQLx writes (YYYY-MM-DD, HH:MM:SS[.f], YYYY-MM-DD HH:MM:SS[.f], YYYY-MM-DDTHH:MM:SS[.f]+00:00), so values of one column sort as text. A ColumnDefault::CurrentTimestamp default is the current UTC time in the column’s form (CURRENT_TIMESTAMP for TIMESTAMPTZ, CURRENT_TIMESTAMP AT TIME ZONE 'UTC' for the zone-less PostgreSQL types, CURRENT_TIMESTAMP(6), (CURRENT_DATE) or (CURRENT_TIME(6)) on MySQL/MariaDB, and on SQLite strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now') for timestamp_tz). These columns and chrono fields need a strict driver feature: on the SQLx Any pool Schema::create/Schema::table reject the native helpers on PostgreSQL and MySQL/MariaDB with Error::Validation before DDL, because a table holding such a column could not be read. RullstValue gains DateTime, NaiveDateTime, Date and Time variants, bound through SQLx’s chrono codecs on strict pools and as that ISO-8601 text on the Any pool. soft_deletes() keeps a TEXT deleted_at column. The Turso adapter (TursoMigration, TursoModel) writes its own SQL, so none of these builders or date/time codecs apply to it; its timestamps stay text.

Blueprint::float is an f64 column on every driver: DOUBLE PRECISION on PostgreSQL (whose REAL is single precision and cannot decode as f64), DOUBLE on MySQL/MariaDB and REAL (8-byte) on SQLite. Column::col_type still reads REAL before the schema is built; an explicitly replaced col_type is emitted unchanged. This affects DDL built from now on; columns created by earlier versions keep their type until a reviewed migration alters them (for example ALTER TABLE t ALTER COLUMN c TYPE DOUBLE PRECISION).

Blueprint::boolean is a bool column: BOOLEAN on PostgreSQL, which has no implicit integer/boolean casts for bound parameters or decoding, and an INTEGER 0/1 flag on MySQL/MariaDB and SQLite. On PostgreSQL an integer ColumnDefault of 0/1 renders as FALSE/TRUE and any other integer default fails the build. Column::col_type still reads INTEGER before the schema is built. Only newly built DDL changes: an existing PostgreSQL column stays INTEGER until migrated (ALTER COLUMN c TYPE BOOLEAN USING c <> 0), and a model that paired boolean() with an integer field on PostgreSQL must use bool for new tables.

The Capital row also includes one implemented, feature-gated quota boundary: BillingSubject binds a shared team/workspace counter to trusted tenant state, Billable::quota_request derives the limit from the subscription owner, and QuotaStore atomically reserves idempotent units before resource creation. The deterministic local store is always available; quota-sql uses fixed-schema SQLx transactions on SQLite/PostgreSQL/MySQL/MariaDB, with exact replay/release and a caller-owned transaction path for atomic domain writes. Live container contracts exercise all four protocols. Membership establishment, plan state, migrations, reconciliation and non-relational adapters remain application boundaries.

3.1. Generated Academy activity boundary (removed in v13)

v13 replaced the complete Academy scaffold with the LMS starter, which became the LMS example (examples/lms) in 13.0.0-alpha.4. The generated ActivityEvaluator, activity attempts, ScoreEvent recording, leaderboards and the spaced-review queue are no longer generated. Their design remains in the git history and on the frozen v12 branch; an application that adds them owns the same server-authoritative controls.

⚡ 4. Core API Specifications (rullst-core)

rullst-core provides the runtime kernel. Database and queue drivers are modular and feature-gated.

4.1. Server & Routing (rullst::routing)

  • Routing Macro: Central declarative routing declared via the routes! macro wrapping Axum routing handlers:
    #![allow(unused)]
    fn main() {
    use rullst::{response::Html, routes};
    
    async fn home() -> Html<&'static str> {
        Html("Home")
    }
    
    async fn posts_index() -> Html<&'static str> {
        Html("Posts")
    }
    
    let router = routes![
        get("/" => home),
        get("/posts" => posts_index),
    ];
    }
  • Server Lifecycle & Graceful Shutdown:
    #![allow(unused)]
    fn main() {
    use rullst::{Server, routes};
    
    async fn serve() -> Result<(), rullst::server::ServerError> {
        let router = routes![get("/" => || async { "OK" })];
        Server::new(router).run(3000).await
    }
    }
    ApplicationLifecycle is the opt-in orchestration contract behind a lifecycle-aware server. Its phase is monotonic, its immutable registry has at most 32 validated required-component labels, and request admission requires both Ready and every component bit. Exact GET/HEAD health probes bypass admission, and the Server rate limiter and Traffic Shield, so /ready can return a bounded 503 during startup, dependency failure or drain while /health stays process-only. The JSON reports counts, not labels or dependency errors. Its version is the framework version; the v13 health_router_with_version and health_router_with_lifecycle_and_version add the application’s own version as app_version. Server::run_with_shutdown accepts a caller-owned future; when it resolves, the lifecycle changes to draining before Axum waits for accepted requests and then becomes stopped. Dependency checks/timeouts, component updates, replica consensus, load-balancer timing, authorization and the deployment termination deadline remain host contracts.
  • Access log and default log filter: Server prints one [HTTP] METHOD path -> status (ms) line per request on stdout. Since 13.0.0-alpha.3, in Staging and Production a /health or /ready request with a status below 400 is not printed (a failing probe still is); RULLST_LOG_HEALTH_PROBES (true/false, also 1/0, yes/no, on/off, from the process environment and then .env) overrides that default in every environment, and any other value stops startup with ServerError::Configuration. The tracing subscriber that Server::run and telemetry::init_telemetry install reads RUST_LOG; when it is unset or does not parse, the filter is info,sqlx=warn, so SQLx’s own connection and statement events below WARN are dropped while rullst_orm spans stay at INFO.
  • Trusted-Proxy Client Resolution (v13): The socket peer (ConnectInfo<SocketAddr>) is the default client identity. security::TrustedProxyLayer implements ProductionMiddlewareStage::TrustedProxy and Server::trusted_proxies(TrustedProxyConfig) mounts it outside the security baseline, lifecycle, Traffic Shield and rate limiter in both the static and hot-reload servers (the hot-reload server now supplies ConnectInfo too). It never rejects a request, so exact health probes keep their rate-limit and Traffic Shield exemption behind it. TrustedProxyConfig::new validates at most 64 IPv4/IPv6 addresses or CIDR networks, rejects /0, host bits and duplicates, and normalizes IPv4-mapped IPv6; an empty policy never reads forwarding headers. Only a trusted socket peer’s X-Forwarded-For (default) or RFC 7239 Forwarded header is read, never both. The list is walked right to left, skipping trusted hops; the first untrusted address is the client, or the left-most when every hop is trusted. Text left of the client entry is never parsed, and at most 4,096 bytes and 32 entries are examined. Ports, brackets and IPv4-mapped forms are normalized; unknown, obfuscated identifiers and malformed entries are rejected. A missing, malformed, oversized or over-long header keeps the peer without failing the request and emits one tracing event without header contents. On success ConnectInfo becomes the client IP with port 0; every request with peer metadata receives a ClientAddr extension without a public constructor. trust_forwarded_proto(true) reports the trusted peer’s scheme (one X-Forwarded-Proto value or the selected Forwarded element’s proto=) as ClientAddr::forwarded_proto. Rullst.toml accepts the same policy as [security] trusted_proxies, trusted_proxy_header and trust_forwarded_proto, validated with the rest of the security configuration; an explicit builder policy replaces it. Only the real proxy networks may be listed, because any host inside them can choose the client address. Since 13.0.0-alpha.2 the static server’s startup line (Rullst framework serving on http://<address>) marks an address that covers every interface (0.0.0.0 or ::, the staging and production default without HOST) and, when a trusted proxy policy is enabled, suggests HOST=127.0.0.1 so a proxy on the same host cannot be bypassed; the default bind address is unchanged.
  • Default Dynamic Cache Boundary: headers_middleware supplies Cache-Control: no-store only when the handler has not already selected an explicit cache policy. Versioned public/static responses can therefore opt into reviewed caching without weakening the default for dynamic data. The standard /static mount does so (v13): 2xx/304 responses for a content-hashed file name get public, max-age=31536000, immutable, other static files no-cache with ServeDir’s ETag/Last-Modified validators; see static assets. Since 13.0.0-alpha.2 rullst::cache_control offers explicit policies for dynamic routes: cache_control::public(max_age) and private(max_age) are layers that set public, max-age=<seconds> or private, max-age=<seconds> on the 2xx/304 responses of the routes they wrap when the response has no Cache-Control yet (errors keep no-store), and CacheControl can also be returned from a handler as a response part. The CSRF middleware never sets its cookie on a response that shared caches may store (a Cache-Control with public or s-maxage and without private or no-store).
  • Strict Transport Security (v13): headers_middleware adds Strict-Transport-Security when the response has none, built from [security] hsts_max_age_seconds (default 31,536,000), hsts_include_subdomains (default true) and hsts_preload (default false): max-age=31536000; includeSubDomains by default. preload is an opt-in consent to the browsers’ preload list; SecurityConfig::validate (and therefore Server startup) rejects it without includeSubDomains or with a max-age below 31,536,000. 13.0.0-alpha.1 always sent max-age=63072000; includeSubDomains; preload. rullst_security::SecureHeadersConfig::default() uses the same default value.
  • WAF Body Exemptions and Development Report (v13): [security] waf_body_exempt_paths lists exact absolute paths (validated like csrf_signed_webhook_paths; invalid entries fail startup) whose request bodies waf_middleware neither reads nor inspects, a deliberate per-route risk acceptance; their path, query, cookies, Referer and User-Agent stay inspected. In development apply_security_baseline mounts a report-only WAF instead of none: it applies the same rules, passes every request (replaying bodies over the 1 MiB limit in full) and logs a warn event (target rullst::security::waf) with only the rule name and path for each request staging and production would refuse. The WAF never reads a multipart/form-data body; it marks such a request on a non-exempt path so that uploads::Multipart inspects each text field it reads (see Multipart Form Uploads).
  • Double-Submit Form Contract: csrf_middleware installs the exact request-scoped CsrfToken used by the CSRF cookie on eligible GET and HEAD requests (HEAD mirrors GET’s headers, including a new cookie, so a get route extracting the token also answers HEAD) and preserves it after a valid state-changing request. Server-rendered forms must echo that value in _token; HTMX/JavaScript may instead send it through X-CSRF-Token. A multipart/form-data form (such as a file upload) must place its _token field before any file input: the middleware reads at most the first 64 KiB of that body to find the field, compares it in constant time and then passes the whole body on unchanged. Since 13.0.0-alpha.2 a multipart body without a readable _token field is refused with a 403 that names this rule (Missing CSRF token: a multipart/form-data form must send its _token field before any file field …) instead of the generic Invalid or missing CSRF token; a readable but wrong token keeps the generic text. A GET or HEAD for a static-looking path (/robots.txt, /sitemap.xml, /favicon.ico, /static/*, or a .txt, .xml, .ico, .json, .css, .js, .png, .jpg, .jpeg, .svg, .webp or .wasm suffix) never mints a token or sets the cookie; since 13.0.0-alpha.2 it still receives the CsrfToken of a valid existing cookie. Nested application and Server baseline composition is request-idempotent: exactly one CSRF layer owns token validation/cookie emission, so an explicitly protected router remains valid when the production server wraps it. The cookie intentionally remains script-readable and must not be confused with an authentication or session cookie.
  • Secure Framework Cookies over HTTPS (v13): the rullst_csrf cookie carries Secure in staging and production and, since 13.0.0-alpha.2, in any environment when the request arrived over HTTPS: an https request URI (HTTP/2 over TLS or an absolute-form target) or a ClientAddr::forwarded_proto of https, which only TrustedProxyLayer sets for a trusted peer with trust_forwarded_proto; a forwarding header from any other peer is ignored. apply_security_baseline (and so Server) mounts a layer outside CSRF in every environment that appends ; Secure to each Set-Cookie of a rullst_* cookie (such as the rullst_session cookie of rullst-auth) without it on a response to such a request. Other cookies are untouched and Secure is never removed.
  • Multipart Form Uploads (v13): uploads::Multipart is an axum extractor for multipart/form-data (a missing or malformed boundary is NotMultipart, 415); Multipart::read(&MultipartPolicy) parses the body incrementally into an UploadForm of text fields and UploadedFiles. MultipartPolicy::default() allows 10 MiB per file, 16 MiB per body (a larger Content-Length is refused before reading), 4 files, 32 text fields of 64 KiB, 128-byte field names, 32 KiB of each file in memory (at most 1 MiB) and 30 s between body chunks; part header blocks are limited to 8 KiB. Exceeding a limit stops reading with a typed MultipartError (413 for sizes and counts, 400 for a long name, oversized headers, malformed structure or a non-UTF-8 field, 408 for the idle timeout). The parser accepts a body that starts with the first boundary, CRLF or -- after each boundary, never reads past the closing boundary, rejects nested multipart/* parts, repeated Content-Disposition/Content-Type headers and repeated disposition parameters, and stays linear for tiny frames and many tiny parts. A part with filename="" and no content (an empty file input) is skipped and not counted. Larger files stream to .rullst-upload-<uuid>.part in temp_dir (default std::env::temp_dir()), created with create_new and mode 0600 on Unix and removed when the UploadedFile drops, also on every error. Before a process first writes a temporary file to a directory, and again once 24 hours have passed (13.0.0-alpha.3), it sweeps the leftovers of a crashed process there: regular files named exactly .rullst-upload-<32 lowercase hex>.part and modified at least 24 hours ago, never a symbolic link or a directory, examining at most 10,000 entries and removing at most 1,000; a failure is a rullst::uploads warning and the upload continues. UploadedFile::persist_to never overwrites (an existing path or symbolic link is PersistTargetExists), requires a file name in an existing directory without .. (InvalidPersistTarget), hard-links on the same file system, copies otherwise, and syncs the new 0600 file. allowed_kinds and expect_signature (1–64 bytes) check the bytes while they stream, with the UploadPolicy signature and plain-text rules (ContentDenied, 415); the declared type and the sanitized display_name (last path segment, no control or invisible format characters, at most 255 bytes) are metadata only. The extractor ignores axum’s DefaultBodyLimit. Where the WAF runs and the path is not in waf_body_exempt_paths, it marks multipart requests and read applies the WAF signatures to each text field name and value: WafRefused with the WAF’s 403 in staging and production, a warn log (rule multipart-field-signature, path only) in development. A first _token field that ends after the first 64 KiB without an X-CSRF-Token header is CsrfTokenTooLate (403, the middleware’s text) in every environment. Error responses are fixed plain text with Cache-Control: no-store.
  • Database URL Resolution: Server and Artisan (artisan!, check_and_run_artisan, db:*, studio) share one resolver: a Server::with_db value when Server::run intercepts the command, the process DATABASE_URL, DATABASE_URL in ./.env (never overriding the process environment), then [database].url parsed as TOML. There is no implicit SQLite fallback: a db:* command without a configured database, and any configuration or Orm::init failure, exits with status 1. Parse errors report positions only, never file content. Server::run has no migration or seeder registry, so a db:* command it intercepts (because the application never called artisan!) exits with status 1 and names rullst::artisan! instead of reporting success for an empty registry; studio still runs. Like Server, Artisan never loads .env into the process environment (13.0.0-alpha.1 did after resolving the database), so a migration or seeder reads other .env values with rullst::config::project_setting and behaves the same under cargo run and cargo run -- db:seed; the legacy studio page checks AI provider keys the same way. Rullst Studio reuses an existing ORM pool; only when none exists does its first database view resolve the current directory with this same resolver and initialize the pool once. Without a configured database Studio reports its database tools as unavailable and creates nothing.
  • Startup Migrations (v13): Server::auto_migrate(bool) and RULLST_AUTO_MIGRATE (true/false, 1/0, yes/no, on/off; the process environment, then .env, overrides the builder; default off) apply the migrations registered with Server::with_migrations (feature orm) after the pool opens and before binding, through the ORM migrate command, in both the static and the hot-reload server. PostgreSQL and MySQL/MariaDB runs hold the runner’s advisory/GET_LOCK lock; an SQLite file run also holds an exclusive OS lock on <database>.rullst-migrate.lock (in-memory databases take none). A failed migration, a missing registry or database, an unparsable value or an untakeable lock stops startup with ServerError::Migration (or ServerError::Configuration for the value); without the orm feature an enabled setting is ServerError::Database. Since 13.0.0-alpha.2 the SQLx starters that cargo rullst new generates (Blank, API and SaaS, including their internal hot-reload layouts; the LMS, Blog, Portfolio and ERP starters of that release are now examples) call with_migrations with their registry and leave auto_migrate unset, so only RULLST_AUTO_MIGRATE turns it on. Turso starters register none: their migrations run through db:migrate, and the variable set to true there fails startup with ServerError::Migration.
  • Transactional Migrations (v13): A migration whose within_transaction() returns true runs up() (or down()) and its migrations record inside one Orm::transaction, which Schema, generated model methods, execute_query!/dispatch_executor! and Orm::raw(sql).execute() join. Since 13.0.0-alpha.3, while that body runs on the runner’s task, Orm::pool(), Orm::read_pool(), their try_ forms and Orm::begin_transaction() return Error::Validation naming the migration instead of a connection outside the transaction, so the run fails and the migration rolls back unrecorded. A pool obtained before the body ran, another crate’s pool and tasks spawned from the body are not detected. PostgreSQL and SQLite roll the DDL back with the record; MySQL/MariaDB commit DDL implicitly.
  • Application Settings (v13): rullst::config::project_setting reads one application setting from the process environment, then from ./.env, which never overrides the process environment and is never loaded into it, neither by Server nor by Artisan commands. An unreadable or malformed .env is a ConfigError that never contains file content. Nexus basic_from_env reads its administrator credentials and generated billing code its BILLING_* settings through it; generated billing treats a production RullstConfig::environment() (or an invalid one) as production.

4.2. Server-Side Rendering (rullst::html!)

  • Macro: html! expands supported HTML trees into ordinary Rust String construction at compile time and returns it as rullst::html::Markup (since 13.0.0-alpha.2; earlier versions returned String).
  • Markup: a newtype over a String of already-escaped HTML. It implements Display, AsRef<str>, Deref<Target = str> (read-only), PartialEq with str, &str and String in both directions, Default, Clone, Hash, From<Markup> for String and for the axum body, and IntoResponse with content-type: text/html; charset=utf-8; into_string() and as_str() return the HTML. There is no mutable access to the text and no conversion from String: only the explicit Markup::from_trusted(impl Into<String>) and From<RawHtml> trust their input. render_page/render_page_with_lang take impl Into<Markup>; Live::mount and native #[island] functions return Markup, and a #[live_component] render method may return Markup or String.
  • Caller bindings: Generated output-buffer locals use hygienic identifiers; interpolated expressions retain caller name resolution, including s in fragments, attributes, nested elements and closures. Each expression keeps its existing evaluation order and escaping behavior.
  • XSS Protection: Dynamic display values in the supported {expr} syntax are HTML-escaped by the generated code.
  • URL attributes: Dynamic values of href, src, action, formaction, poster, data, cite, background, codebase, icon, longdesc, manifest and usemap (names matched case-insensitively) are also checked at runtime. A javascript: or vbscript: scheme, or data: outside img, audio, video, source, track and image, renders as #. Leading C0 controls or spaces and embedded tab/LF/CR are ignored while reading the scheme, as browsers do. Static attribute strings are author-owned and unchanged; entity references inside an explicit RawHtml value are not decoded before the check.
  • Event-handler attributes: A dynamic value for an on* attribute or htmx’s hx-on/hx-on-* (also data- prefixed) is a compile-time error, because the browser runs it as JavaScript and escaping cannot make interpolated data safe there. Static handler strings remain allowed; attach data-driven listeners from a nonce’d script and pass values in data-*.
  • Trusted-only style: Dynamic style values are only HTML-escaped. They cannot leave the attribute and supported browsers do not run script from CSS, but untrusted CSS can restyle or overlay the page and load remote resources, so pass only trusted or validated values.
  • Composition: An interpolated Markup (or &Markup) is inserted as it is, so a fragment built by another html! call nests without a wrapper, while String, &str, numbers and other values are escaped. Option<T> renders nothing for None and T for Some; an iterator of Markup collects into one Markup (FromIterator, Extend). Collecting Markup into a String deliberately does not compile. In an attribute value (name={value}) a Markup is escaped like text (HtmlEscape::escape_attribute), because HTML there could end the attribute; RawHtml stays verbatim in both contexts.
  • Raw Unescaped HTML: rullst::html::RawHtml(String) (or Markup::from_trusted) is the explicit escape hatch for trusted HTML produced outside html!, such as a sanitizer’s or renderer’s output. It is never needed for html! output and must never wrap request data.
  • Attributes: name="text" is author-owned source and written as it is. name={value} is always written with the value escaped: a bool as "true"/"false" (what aria-* and data-* expect) and None as an empty value. Since 13.0.0-alpha.2 a bare name (<input required disabled>) is written without a value, and a conditional name?={value} is decided at runtime through rullst::html::OptionalAttribute: a bool writes the bare name or nothing, and an Option<T> writes name="value", escaped exactly like name={value} (URL attributes keep the scheme check above), or nothing for None. Any other value type is a compile-time error at the expression. HTML boolean attributes mean presence, so disabled="false" still disables a control: use disabled?={flag}. The quoted forms keep compiling. Attribute names are Rust identifiers (keywords included) joined by -, so no quote, =, > or whitespace can reach the output through a name; : and @ name forms are not supported. A conditional value on an event-handler attribute is rejected like a dynamic one, while a bare handler name is static.
  • Control flow: Since 13.0.0-alpha.2, @if condition { … } (also @if let PATTERN = value), @else if …, @else { … } and @for PATTERN in iterable { … } take html! nodes in their braces; the heads are parsed like Rust’s own if/for heads and keep caller name resolution. Since 13.0.0-alpha.3, @match value { PATTERN => { … } … } does the same for match: each arm is a Rust pattern (| alternatives and name @ bindings included) with an optional if guard, its body is in braces, and commas between arms are optional. The arms expand to a Rust match, so exhaustiveness and pattern errors are reported by the compiler at the caller’s tokens, and bodies follow the same escaping and Markup rules as every other node. An arm body without braces is a compile-time error that shows the braced form. No other node can start with @, so the forms are unambiguous. A plain else after an @if body and any other @ form (@let, @while, @loop) are compile-time errors: other control flow stays a Rust expression that returns Markup inside {…}.
  • Element names and the doctype: Element names (opening and closing) may be Rust keywords, such as SVG’s <use>. A leading <!DOCTYPE html> (any letter case) is emitted as written; it must precede every other node (comments, which produce no output, may come first), and other declarations are compile-time errors.
  • Memoize keys: #[memoize] keys its process-local cache entries by the function’s module_path!(), name and attribute location plus the serialized arguments, so same-named functions in different modules, crates or impl blocks never share results. It remains tenant- and invalidation-unaware. The process-wide store is bounded: at most 4,096 entries and 32 MiB of key plus value bytes, each entry at most 256 KiB and one hour old. Oversized entries run uncached; otherwise expired and then the oldest entries are evicted. Arguments that serde_json cannot represent (a failing Serialize impl, or a u128/i128 outside the 64-bit range) also run uncached instead of panicking, as do arguments whose JSON would equal another value’s (NaN or infinite floats, Some(()), Some(None)); such results are never cached; an #[island] with such props renders its server HTML with an empty data-props, so hydration is skipped. A memoized function may return Markup; its HTML is cached and read back as the same Markup.
  • Example:
    #![allow(unused)]
    fn main() {
    use rullst::html;
    
    let username = "<script>alert('xss')</script>";
    let rendered = html! {
        <div class="user-badge">
            <span>"User: "{username}</span>
        </div>
    };
    // Automatically escapes to: &lt;script&gt;alert('xss')&lt;/script&gt;
    
    // `rendered` is Markup: another html! inserts it without escaping it again.
    let card = html! { <section>{rendered}</section> };
    assert!(card.starts_with("<section><div class=\"user-badge\">"));
    
    // Bare and conditional attributes, `@if`, `@for` and `@match`.
    let tasks = [("Write tests", true), ("<Ship>", false)];
    let list = html! {
        <ul>
            @for (title, done) in tasks {
                <li><input type="checkbox" disabled checked?={done} />{title}</li>
            }
        </ul>
        @if tasks.is_empty() { <p>"Nothing to do"</p> }
        @match tasks.iter().filter(|(_, done)| !done).count() {
            0 => { <p>"All done"</p> }
            1 => { <p>"One task left"</p> }
            left => { <p>{left} " tasks left"</p> }
        }
    };
    assert_eq!(
        list,
        "<ul><li><input type=\"checkbox\" disabled checked />Write tests</li>\
         <li><input type=\"checkbox\" disabled />&lt;Ship&gt;</li></ul>\
         <p>One task left</p>"
    );
    }

4.2.1. Translations and Locale Negotiation (rullst::i18n, since 13.0.0-alpha.2)

  • Placement: the runtime is the Core module rullst::i18n (no Cargo feature, not built for wasm32); the catalog macro is rullst_macros::i18n_catalog, re-exported as rullst::i18n::catalog!. Guide: Translations and Locales.
  • Catalogs: catalog!(default = "tag", dir = "path") (dir defaults to locales, relative to CARGO_MANIFEST_DIR; absolute paths are rejected) reads every *.toml file of the directory at compile time, at most 64 files of at most 1 MiB. The file stem is the locale tag, language[-Script][-REGION] in canonical case. Nested tables form dotted keys of ASCII letters, digits, _ and -. A table whose entries are all strings named after CLDR categories and that has other is a plural message; since 13.0.0-alpha.3 any of zero, one, two, few, many and other the locale’s rule selects is accepted. Templates use {name} placeholders (Rust identifiers that are not keywords) and {{/}}.
  • Compile-time checks: the build fails on a key missing from any locale, differing placeholder sets or plural/single shapes between locales, a malformed template, a plural message whose forms do not match the locale’s rule, a non-canonical file tag, and a default without a file (at most 30 catalog errors are listed). Each file is tracked with include_bytes!, so edits rebuild the crate. Stable Rust gives a macro no way to watch a directory, so the documented setup adds a build.rs printing cargo::rerun-if-changed=<dir>: Cargo then rescans the directory and rebuilds the crate when a locale file is added or removed (rullst-macros/tests/locale_rebuild.rs builds a probe crate with and without it).
  • Generated items: in the invoking module, pub static LOCALES: rullst::i18n::Locales (tags sorted, default index) and macro_rules! t re-exported with pub(crate) use t;. t!(locale, "key", name = expr, …) takes a Locale or &Locale and a string-literal key; an unknown key (with a near-miss suggestion), a missing, unexpected or repeated argument, and a plural message without count are compile errors at the caller’s tokens. Arguments are evaluated once, in call order. After a catalog error the macro still defines both items so that only the catalog error is reported.
  • Output and escaping: t! returns Cow<'static, str> plain text (borrowed when the message has no placeholders) that html! escapes in text and attributes; arguments use Display. A key ending in _html returns Markup: its catalog template is trusted and its arguments are HTML-escaped.
  • Plural rules: Since 13.0.0-alpha.3 forms are selected with CLDR’s cardinal rules (checked against CLDR 48’s sample numbers) for ar, cs, de, en, es, fr, he, it, ja, ko, nl, pl, pt (pt, pt-BR), pt-PT, ro, ru, tr, uk and zh, any region or script. A plural message must define exactly the categories its locale’s rule selects: other only in ja, ko and zh; one/other in de, en, nl and tr; one/few/many/other in cs, pl, ru and uk; one/few/other in ro; one/two/other in he; all six in ar. es, fr, it, pt and pt-PT need one/other and may add many, which CLDR selects for exact millions (other is used when it is left out). A missing category is a compile error naming it; a category the rule never selects is one too. A plural message in another language, or a Portuguese region other than Brazil and Portugal, is a compile error. count accepts any integer and rullst::i18n::Decimal through PluralCount (the absolute value; integers beyond u64 saturate). A Decimal keeps its visible fraction digits, which CLDR’s operands read (1.0 is English other, 1.5 is French one); compact exponents are not modelled. Locale::plural_category(count) returns the PluralCategory (#[non_exhaustive]), or None for a language without a rule.
  • Message selection: a call selects the catalog locale with the Locale’s tag, else one with the same language (the catalog default first), else the catalog default, so a Locale from another crate’s list still renders.
  • Locale and Locales: Locale is Copy and exposes tag() and Display; it is obtained from Locales::get (ignoring ASCII case), iter, default_locale, negotiate, cookie_locale, accept_language or the extractor. Locales::new(&'static [&'static str], default) validates 1–64 canonical, distinct tags and a listed default, returning LocaleError (Empty, TooMany, InvalidTag, DuplicateTag, UnknownDefault; non-exhaustive).
  • Negotiation: Locales::negotiate(&HeaderMap) returns the first supported tag in a rullst_locale cookie (LOCALE_COOKIE, across all Cookie headers), else the best match of the first Accept-Language header, else the default. An Accept-Language value over 4,096 bytes is ignored; only its first 32 ranges are read, and ranges over 35 bytes, with q=0 or with a weight outside RFC 9110’s 0[.ddd]/1[.000] are skipped. Ranges are ordered by weight with header order kept on ties; each matches a supported tag exactly (ignoring case), else by primary language (the default first, then list order), and * matches the default.
  • Layer and extractor: Locales::layer(&'static self) returns LocaleLayer, whose service inserts the negotiated Locale into the request extensions. Locale implements FromRequestParts; without the layer it rejects with MissingLocale, a 500 naming the layer.
  • Switch route: Locales::switch_route(&'static self) is a POST MethodRouter reading a URL-encoded body of at most 4,096 bytes (413 above) with locale and optional next. A malformed form or an unsupported locale is 400 without a cookie. Otherwise it sets rullst_locale=<tag>; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly, with ; Secure when the request’s Environment extension (else the detected environment, else secure) requires secure defaults, and answers 303 See Other to next if it is at most 2,048 bytes of printable ASCII starting with one / and containing no \, else to /. With HX-Request: true it answers 204 with HX-Redirect instead of Location. CSRF protection comes from the application’s csrf_middleware/security baseline; the route does not exempt itself.
  • Page language: handlers pass locale.tag() to render_page_with_lang for <html lang>.
  • Formatting: Since 13.0.0-alpha.3 Locale::format_number(impl Into<Decimal>), format_percent(ratio), format_currency(minor_units: i64, Currency), format_date(&impl chrono::Datelike, DateStyle), format_time(&impl chrono::Timelike, TimeStyle) and format_datetime return Strings with CLDR 48 conventions compiled in (generated from ICU 78 and CLDR’s date patterns by .github/generate-i18n-format-data.py; no runtime data or network). Data exist for ar, cs, de, en, en-GB, es, fr, he, it, ja, ko, nl, pl, pt, pt-PT, ro, ru, tr, uk and zh; another tag uses its language-REGION entry, else its language’s, else en’s (Locale::format_data_tag). Numbers use the locale’s decimal and grouping separators, minimum grouping digits (es, it, pl, pt-PT write 1234), minus sign and Latin digits, and show every fraction digit of the Decimal (Decimal::round_half_even and round_half_up round explicitly). Percentages multiply the ratio by 100. Currency (BRL, USD, EUR, GBP, JPY constants with localized symbols; Currency::new(code, minor_digits) for other ISO codes, written as the code; CurrencyError::{InvalidCode, TooManyDigits}) takes amounts in integer minor units, so no floating-point rounding occurs. DateStyle is Short, Medium, Long, Full (CLDR’s date formats) or Numeric (CLDR’s yMd skeleton); TimeStyle is Short or Medium (no zone names); both are #[non_exhaustive]. Date and time are joined with CLDR’s date-time format for the date style (ICU’s atTime variant for Long and Full; Numeric joins like Short). Values are formatted in their own offset; years before 1 are written as ISO numbers. Outputs keep CLDR’s U+202F and U+00A0 spaces. Tests compare every locale with ICU output.
  • Out of scope: runtime-loaded catalogs, ordinal plural rules, script-aware matching, compact and spelled-out numbers, time zone names, non-Gregorian calendars and non-Latin digits. rullst-mail’s MailLocale remains separate (MailLocale::from_preference(locale.tag())).

4.3. Durable Queue Timing and Completion History

  • SQLite and Redis persist dispatch_at for at most 366 days and never claim a job before its stored millisecond due time. Execution remains poll-dependent and at-least-once.
  • The v13 opt-in Queue::dispatch_deferred places a job by a deadline and up to 24 daily fixed-offset windows: the start of the next allowed window (immediately inside an open one), never after the deadline, and the deadline when no window opens before it. dispatch_deferred_with places it at the lowest-value slot of an application-provided CarbonIntensitySource inside those windows, falling back to the window rule when the source fails or exceeds its timeout. Placement goes through dispatch_at, so stored rows and keys are unchanged; Core ships no network source and makes no emissions claim. Scheduler::deferrable_task applies the window rule to each tick within a maximum delay.
  • Scheduler::task matches POSIX five-field cron against UTC. The v13 opt-in scheduler-tz Core/facade feature adds Scheduler::task_in_tz(expression, zone, handler), which matches the same fields, day fields included, against the wall clock of an IANA time zone. An unknown zone is rejected at registration with SchedulerError::InvalidTimeZone { zone, message }. A wall time that a daylight-saving gap skips runs at the first instant after the gap (several skipped matches run once there); a wall time that a fold repeats runs once, at its first occurrence, and no match in the repeated hour runs a second time. Zone rules come from the system zoneinfo database when it has the zone, otherwise from the database bundled with the jiff dependency. Fixed instants in tests cover São Paulo, New York’s gap and fold, and UTC equivalence with Scheduler::task. The 13.0.0-alpha.3 Scheduler::deferrable_task_in_tz(expression, zone, deferral, handler) matches the ticks as task_in_tz does and reads the deferral’s windows as wall times of the same zone, with the same rule for their boundaries (a skipped boundary moves to the end of the gap, a repeated one is its first occurrence; a window a gap skips entirely does not open that day); max_delay stays elapsed time, and a window that sets a UTC offset is rejected with InvalidConfiguration. Fixed instants around New York’s spring and autumn changes cover it.
  • SQLite claims pending jobs in order of their due time, and an immediately dispatched job is due at its enqueue time. A scheduled job, or one handed back for lack of a handler, that became due earlier is therefore claimed before later immediate jobs instead of waiting for the backlog to drain.
  • Successful SQLite jobs are deleted by default. The explicit Queue::sqlite_with_completed_history constructor validates a 1–100,000 row limit, changes a processing row to completed, and prunes excess history in the same transaction.
  • Queue::purge_completed_history removes those opt-in retained successes. Rows contain the original payload, so Studio access, data minimization and retention policy remain host responsibilities. Redis/custom drivers expose inspection or history only when their capability implements it.
  • Redis stalled-lease recovery measures each lease’s age with Redis server time, the clock that scored the claim, so a worker host’s clock skew cannot recover a running job early or postpone recovery of a crashed one.
  • The Redis queue waits up to 10 seconds for each command’s reply (the cache keeps the 500 ms redis-rs default). Redis still applies a script whose reply times out, so a claim lost that way is returned only by stalled-lease recovery; the longer deadline keeps ordinary latency spikes from causing it.
  • SQLite never recovers a lease early. A claim with a lease stalls once its millisecond expiry has passed; a claim made without one (pop) records only whole seconds, so it is recovered between stalled_after and about two seconds later.
  • A worker without a handler for a claimed job’s name hands it back with a five-second delay until the claim’s 720th attempt, then fails it with HandlerNotFound, so a job whose name no worker registers reaches the failed state instead of cycling forever. Queue::dispatch and dispatch_at reject empty job names and names longer than 256 bytes.

Local object replacement

LocalDriver::put, and Storage::local/TenantStorage over it, never truncates an existing object. It writes a uniquely named temporary file in the validated destination directory (create_new), flushes it with sync_all and renames it over the destination. A failure removes the temporary file and leaves the previous version. Readers and concurrent writers observe exactly one complete version; the last rename wins. The replacement is a new file with default permissions, so permissions or hard links of the previous file are not carried over. The directory is not fsynced, so a power loss can roll a completed put back to the previous version. On Windows a replacement fails while another process holds the object open without delete sharing. Like the cloud backends, local exists, get, metadata and delete treat a key that names a directory, or that continues below a regular file, as a missing object rather than an I/O failure.

Public object URLs

Storage::url and LocalDriver::url percent-encode every byte of each key segment except the unreserved A-Z a-z 0-9 - . _ ~, so #, ?, %, spaces and non-ASCII characters stay part of the object key. Local storage returns the root-relative /storage/<key> path whatever its base directory, so the filesystem path is never disclosed; the application must serve that directory at /storage (Rullst does not mount it). An unconfigured S3 driver returns the provider’s unsigned object URL, following the cloud client’s endpoint rules (amazonaws.com.cn for cn-* regions, path style for dotted bucket names). An R2 driver returns StorageError::Unsupported: R2’s S3 API endpoint never serves anonymous reads, and its public r2.dev or custom domain is application-owned Cloudflare configuration. A configured private backend rejects url() and requires a signed download.


4.4. Private S3-compatible storage (v13, source admitted)

The opt-in storage-s3 Core/facade feature extends the existing Storage and TenantStorage APIs with explicit AWS S3 or Cloudflare R2 credentials. It must not add cloud dependencies to bare Core or infer credentials from the ambient environment. The provider constructors alone remain unconfigured until a validated cloud configuration is attached.

The supported journey is bounded object upload, download, metadata, deletion and short-lived signed GET URLs. Use the maintained AWS SigV4 signer, explicit provider endpoint/region rules, HTTPS, no redirects or ambient proxies, bounded request/body budgets and redacted errors. A separate development configuration may target a literal loopback endpoint for disposable protocol acceptance; it must not pass a production-configuration check. Request paths are the SigV4 canonical URI: the bucket and each key segment are UriEncoded once, leaving only unreserved A-Z a-z 0-9 - . _ ~ and / separators literal. Empty or mock_* credentials select a deterministic bounded in-memory fallback, also rejected by that check. No mock URL may impersonate a signed provider URL.

Tenant wrappers bind all five operations to the authenticated namespace. The application still authorizes the current account and object before every operation or grant; namespacing alone is not authorization. Private access URLs are bearer secrets, remain usable until expiry/provider invalidation, and must not appear in Debug/errors. They are not single-use or individually revocable. Direct presigned uploads, bucket/IAM provisioning, image processing and arbitrary S3 operations are outside this increment. Existing file quarantine/admission remains an explicit application step before cloud persistence or sharing.

Acceptance requires feature-off/on and MSRV checks, an extracted-package consumer, a tenant-bound application journey, independent signature/protocol fixtures, bounded failure/expiry tests and a real disposable S3-compatible service. Those tests do not establish interoperability with owner accounts, provider configuration or regional legal compliance. Source/package admission passed in PR #236; final release admission remains separate.

Resumable multipart candidate (source admitted, v13 prerelease)

The separate opt-in storage-multipart extends this adapter with server-mediated initiation, exact-size SHA-256-checked parts, bounded remote reconciliation, completion and abort. A server-owned MultipartKey seals resumable checkpoints with AES-256-GCM under a versioned purpose and exact endpoint/bucket/region/mock profile/object binding (including the ephemeral instance in mock mode). Each operation reopens the checkpoint and checks policy; tenant wrappers derive that object only from the authenticated namespace. The application must still authorize the current subject/object and serialize or CAS checkpoint updates. A checkpoint is sensitive recovery state, never identity, membership, malware clearance or permission to publish an object.

The initial scope fixes total length, part size (5–64 MiB), at most 256 parts and a lifetime of one minute through seven days. Part bytes match a caller-supplied SHA-256 before signing the exact payload with SigV4. ETags are opaque provider receipts, not content hashes. Checkpoints retain receipts; resume lists provider parts and reports missing/mismatched receipts for explicit re-upload. Completion requires every consecutive receipt and rejects an embedded XML error even under HTTP 200. A provider marker and exact length support explicit reconciliation of lost completion responses, without guessing from object existence alone.

Only one part is buffered per call; applications bound concurrent calls. Responses are byte/node bounded, prohibit DTDs and validate exact root/resource identity. No public ACL, direct browser upload or ambient endpoint is introduced. Existing ordinary-object download limits still apply to large completed objects. New objects may replace a same-name object as with put; applications must allocate fresh private/quarantine names and withhold reads until scanning and authorization.

Abort remains available for expired, authentic checkpoints and checks provider absence; concurrent in-flight writes can require another abort. Retain cleanup records durably and provision provider lifecycle expiration for abandoned uploads, including initiation whose response was lost before an upload ID was obtained. No application database, global sweeper, bucket provisioning or durable revocation of checkpoints is implied. The offline backend has explicit bounded in-memory state; native disposable S3, process/service restart, forged checkpoints, tenant isolation, response failures and extracted consumers are required evidence.

🗄️ 5. Active Record ORM & Schema Engine (rullst-orm)

5.1. Model Definition & CRUD

#![allow(unused)]
fn main() {
use rullst_orm::{FromRow, Orm};

#[derive(Debug, Clone, FromRow, Orm)]
#[orm(table = "users")]
pub struct User {
    pub id: i32,
    pub name: String,
    pub email: String,
    #[orm(encrypted)]
    pub secret_token: Option<String>,
}

async fn use_users() -> Result<(), rullst_orm::Error> {
// Queries (after `Orm::init(...)` and schema migration at startup).
let all_users: Vec<User> = User::all().await?;
let user: Option<User> = User::find(1).await?;

// Mutations
let mut new_user = User { id: 0, name: "Alice".into(), email: "[email protected]".into(), secret_token: None };
new_user.save().await?; // Auto-executes parameterized INSERT or UPDATE
new_user.delete().await?;
let _ = (all_users, user);
Ok(())
}
}

save() of a persisted model without a RowSnapshot field is a full-row UPDATE of every persisted column except the soft-delete marker, so the last writer wins: a column another writer changed after the handle was loaded is overwritten with the handle’s value. An INSERT likewise binds every persisted field, so database column defaults do not apply to them, except to fields marked #[orm(db_default)] (v13): the generated INSERT omits them, so the column DEFAULT applies, and no UPDATE of save() writes them, so the stored value stays; without dirty tracking the handle keeps its own value until the row is read again, update_partial() has no setter for them and #[derive(Nexus)] shows them read-only. The option is rejected on id, the tenant column, relations, skipped and encrypted fields, and by Turso-primary models. The generated save() rustdoc and the ORM guide name the two supported alternatives: update_partial() (§5.4.1), which merges selected fields into the freshly read and, on PostgreSQL and MySQL/MariaDB, locked row, and one atomic raw statement such as UPDATE ... SET xp = xp + ? WHERE id = ? for counters, which bypasses the model lifecycle (tenant scope, policy, hooks, audit, cache). The driver contract runs that counter and the stale-handle overwrite on SQLite, PostgreSQL, MySQL and MariaDB.

Dirty tracking (v13). A SQLx model opts in with exactly one field of type RowSnapshot (rullst_orm::RowSnapshot, matched by its last path segment) marked #[sqlx(skip)]; a RowSnapshot field without #[sqlx(skip)], or a second one, fails compilation. The tracked columns are the columns of the UPDATE: every persisted field except id, #[orm(db_default)] fields and the soft-delete marker. Rows decoded by the generated reads (get and the methods built on it, relations, streams, query-cache hits and the row update_partial() locks) record, after decryption and before after_fetch, one keyed 64-bit SipHash per tracked column of its serde_json serialization (SecretString contributes its plaintext; #[sqlx(json)], Json<T> and serde_json::Value columns their document with object keys sorted at every level; a value that cannot be serialized never matches). The snapshot is 16 bytes plus 8 per column and stores no value. save() of a persisted record runs the before_save hook and saving observers, then compares: with no difference it returns Ok and runs nothing else (no updating/updated/saved observer, after_save hook, audit entry or post-commit effect); otherwise it runs the updating observers, recomputes the difference and issues UPDATE <table> SET <changed columns> WHERE id = ? (plus the tenant predicate) with RETURNING * on PostgreSQL and SQLite, or followed by SELECT * ... WHERE id = ? in the same transaction on MySQL/MariaDB. No row is RecordNotFound (the tenant-scope Validation error on tenant models). The handle’s unwritten tracked columns, #[orm(db_default)] columns and soft-delete marker take the stored values, so observers, the audit diff, events and the Scout document see the stored row, and the snapshot is recaptured. An insert records the snapshot of the inserted values. Without a before_save hook, policy or registered observer, an unchanged save()/save_with_tx() returns before opening a transaction or savepoint (it still applies and checks the tenant context). A failed save restores the previous snapshot, so its changes stay pending; a rollback of a caller-owned transaction after a successful save is not visible to the handle. A record built in code, one from a Redis hash, or after mark_all_dirty() is untracked and writes every tracked column. Tracked models also get dirty_columns() and is_dirty(). RowSnapshot is bookkeeping: any two compare equal, hash alike and order equal; Debug shows only whether it is tracked; it implements no Serde trait. Turso-primary models do not track. Models without the field keep the full-row UPDATE. Since 13.0.0-alpha.3 every SQLx model the CLI writes (make:model, generate:models, auth, make:billing, make:chat-session and the cargo rullst new starters) declares #[sqlx(skip)] pub snapshot: RowSnapshot and builds new records with RowSnapshot::default(); generate:models adds #[serde(skip)] and omits the field for a table with a snapshot column.

From<sqlx::Error> for rullst_orm::Error maps RowNotFound to RecordNotFound, a unique or primary-key violation to UniqueViolation { constraint } and a foreign-key violation to ForeignKeyViolation { constraint } (both v13, #[non_exhaustive] variants); every other database error stays DatabaseError(String). constraint is the name PostgreSQL reports and None on SQLite and MySQL/MariaDB. Their Display names only the constraint, never the database message, which can quote the rejected value. Generated model writes and builders reach the caller through this conversion, and Orm::raw uses it too.

Orm::transaction returns Result<R, E> for a closure error type E: From<rullst_orm::Error> + Display. After the rollback, the closure’s Err reaches the caller unchanged, so a violation keeps its variant and an application error its type. Failures of the transaction itself (begin, savepoint, release, commit, post-commit effects) are rullst_orm::Errors converted with E::from; a commit rejected by a deferred constraint is a typed violation too. Only a rollback that fails after a failed closure returns E::from(DatabaseError(..)), naming both errors. 13.0.0-alpha.2 returned Result<R, rullst_orm::Error> and reported a failed closure as DatabaseError("Transaction failed: ...").

The Orm derive grammar is fail-closed. Model and field attributes are parsed as structured nested metadata; unknown or duplicate options are compile errors. Every SQLx model requires a persisted named id field; a model whose only persisted field is id inserts the column defaults (DEFAULT VALUES, or () VALUES () on MySQL/MariaDB). Explicit table/column/relation identifiers use the 1–64 byte portable ASCII identifier grammar; the derived <struct>s default table name must match it only when no explicit table replaces it (for example for a non-ASCII struct name), and declared hook, scope, policy, relation-model, tenant, soft-delete, and embedding references are validated before code generation. A relation field accepts exactly one relation declaration; options that do not apply to that relation fail compilation. belongs_to_many requires pivot_table and defaults omitted owner/related pivot keys from the two model names.

Generated SQL emits table and column identifiers unquoted, so the grammar check does not make a name portable: a word the target database reserves (for example order, group, desc or user on PostgreSQL, or groups and rows on MySQL 8, including a derived default such as groups for a Group struct) passes compilation and fails, or on PostgreSQL may even resolve to a built-in such as current_user, at runtime. The derive does not check reserved words; rename such a column or choose a non-reserved #[orm(table = "...")]. Because PostgreSQL folds those unquoted names to lower case, Schema::create and Schema::drop_if_exists quote the table name lower-cased on PostgreSQL, so Schema::create("UserProfiles", ...) creates the userprofiles relation that a model with #[orm(table = "UserProfiles")] queries. MySQL/MariaDB and SQLite keep the name as written.

Only skip, default, json, and json(nullable) from SQLx field metadata are compatible with generated ORM persistence in v12 and v13. Two field options keep a field out of generated writes, with different meanings:

OptionThe columnGenerated reads (FromRow)INSERT / save() / update_partial()WHERE / ORDER BY / GROUP BY / select
#[orm(skip)]is not a database column for the ORMthe application’s SQLx FromRow still reads it unless #[sqlx(skip)]never writtenrejected at run time with Validation
#[orm(db_default)]exists and is managed by the database (a default, a trigger or explicit SQL)readnever written (the INSERT takes the column DEFAULT)allowed

#[orm(skip)] removes a field from generated SQL (writes, filters and projections) but not from the application’s SQLx FromRow, which still reads the column from SELECT *. A field without a table column therefore needs #[sqlx(skip)] (alone or together with #[orm(skip)], which are distinct options) or #[sqlx(default)]. A column the table has that generated writes must not touch is declared #[orm(db_default)], which keeps it filterable; the builder’s error for a skipped column names that option. Until 13.0.0-alpha.2 the SaaS, LMS and cargo rullst auth User models declared is_admin and session_epoch #[orm(skip)]; from alpha.3 they use #[orm(db_default)], and cargo rullst upgrade reports the old declaration (V13-ORM-SKIP-MANAGED-COLUMNS). FromRow decodes a json or json(nullable) field through SQLx Json, and generated INSERT/UPDATE statements (including update_partial()) encode it the same way: the value is bound as sqlx::types::Json(value), and a json(nullable) None as SQL NULL. The field type therefore needs Serde Serialize/Deserialize, not an SQLx Encode, and the column the driver’s JSON type (JSONB/JSON on PostgreSQL, JSON on MySQL, text on SQLite). SQLx implements Json only for the concrete drivers, so such models require a strict driver feature; the dynamic Any pool cannot decode them. rename, try_from, flatten, and unknown SQLx options fail compilation instead of letting the decoded shape drift from generated SQL. The same applies to struct-level SQLx metadata, which FromRow also reads: only #[sqlx(default)] is accepted on the model, and rename_all or any other container option fails compilation. Soft-delete sentinel expressions are bounded compile-time SQL fragments, not parameterized runtime values: they are capped at 128 bytes and reject statement separators, NUL, and SQL comments, while portability and semantic review remain the model author’s responsibility.

5.2. Parameterized Queries & Privacy

  • Values accepted by non-raw query APIs use SQLx parameterization. Structural identifiers use the bounded ASCII grammar. pgvector helpers bind canonical vector/distance strings after rejecting empty/non-finite vectors and invalid distances; they do not interpolate those runtime values. Methods explicitly suffixed/named raw remain caller-owned escape hatches rather than an injection-safety claim.
  • Schema DDL cannot bind values. ColumnDefault::Text and Blueprint::enum_col variants are emitted as single-quoted literals with doubled single quotes, and building the schema rejects such text when it contains a backslash (an escape character in MySQL/MariaDB’s default SQL mode) or a control character.
  • Generated builders assemble bindings by emitted clause position (CTE, SELECT, JOIN, WHERE/HAVING, ORDER BY), not by the order in which fluent methods were called. Nested typed subqueries export that ordered binding sequence. Caller-owned raw CTE and select fragments supply their own values through with_raw_bindings, with_recursive_raw_bindings and select_raw_bindings (v13); a marker/binding count mismatch fails closed, and $n markers are rewritten like typed subqueries. Pluck and count replace the select list and therefore omit its values. with_raw, with_recursive_raw and select_raw fail with Validation when their SQL contains a bind marker, because bind() appends WHERE values and a marker before FROM would take the tenant or model-wide scope binding.
  • Generated builders start with the global row cap from Orm::set_max_query_limit (1,000 by default; 0 disables it). limit() clamps to it and unsafe_unlimited() removes it for one query. An offset() without a limit is emitted after LIMIT -1 on SQLite and LIMIT 18446744073709551615 on MySQL/MariaDB, which accept OFFSET only after LIMIT; PostgreSQL receives OFFSET alone. paginate(page, per_page) clamps per_page to the same cap and reports the effective value in PaginationResult::per_page and last_page.
  • count() and the paginate() total count the rows get() would return without its limit and offset. With distinct() or group_by() they count that row query as a derived table (SELECT COUNT(*) FROM (...) AS __rullst_count, keeping a DISTINCT select list); otherwise they count the filtered rows directly.
  • where_exists, or_where_exists, with_cte and with_recursive embed a subquery with portable ? markers, even when its own to_sql() rendered PostgreSQL $n markers. The outermost statement (including delete_all) is numbered once, so $n follows textual order and the mandatory tenant or model-wide scope binding can never shift onto a nested or caller value. A custom subquery whose $n markers are mixed with ?, reference a missing binding or leave a binding unused fails closed with a Validation error.
  • delete_all() renders only the WHERE and soft-delete predicates. A builder with an explicit limit() (the implicit global cap does not count), offset(), order_by(), joins, group_by()/HAVING or CTEs fails with Validation instead of silently deleting every matching row. On a soft-delete model, with_trashed() and only_trashed() fail the same way: the soft-delete UPDATE matches only live rows, like the instance delete(), so a trashed row keeps its deletion time. Purge trashed rows with force_delete().
  • Only PostgreSQL statements are renumbered. delete_all(), including the soft-delete UPDATE that cascade_soft_delete issues for child rows, keeps ? markers on MySQL/MariaDB and SQLite; the SQLite test and the live PostgreSQL, MySQL and MariaDB matrices execute filtered, tenant-scoped and cascading bulk deletes. Instance restore() and force_delete() statements, including their tenant predicate, are numbered the same way and run in those matrices too.
  • The PostgreSQL renumbering skips quoted text using PostgreSQL’s default standard_conforming_strings = on rules: a backslash escapes the next character only inside E'...' strings, so a raw fragment such as ESCAPE '\' does not hide the markers after it. A server configured with standard_conforming_strings = off is outside this contract.
  • Generated magic filters bind supported primitive fields to their Rust type at compile time (String, i32, f64, and bool), and generated column enums make unknown columns unrepresentable on typed paths. String-column builders, custom RullstValue conversions and raw SQL are explicit runtime-checked or caller-owned alternatives, not compile-time schema verification.
  • RullstValue, #[non_exhaustive] since v13, carries String, Int (i32), Float, Bool, I64 and Null. i64 and u32 convert to I64 and Option<T> to T’s value or Null; an integer literal stays Int. Generated builders and Orm::raw bind every value through one hidden BindRullstValue helper: I64 as i64 and Null as a text-typed SQL NULL, which PostgreSQL accepts in a non-text column only through a cast. A comparison with Null never matches (and PostgreSQL rejects it against a non-text column), so where_null expresses that filter. Tenant IDs take the same values: an I64 tenant gets its own audit (i64:) and cache keys but does not convert to an i32 tenant field (generated queries fail with Validation, §5.4), and a Null tenant fails the audit lookup with Validation.
  • A per-column helper (where_{column}, or_where_{column}, where_not_{column}, order_by_{column}, order_by_{column}_desc) is not generated when its name equals a fixed builder method (for example where_raw, where_exists, where_column, where_col, where_similar or order_by_desc for columns raw, exists, column, col, similar or desc) or when two columns would produce the same name (x and not_x both yield where_not_x). Those columns remain available through the string-column methods. The update_partial() builder keeps its model reference under the reserved __rullst_ prefix and emits no setter for a column named save or save_with_tx.
  • String and Option<String> fields annotated with #[orm(encrypted)] are encrypted before generated ORM writes and decrypted after generated model reads using AES-256-GCM. Randomized ciphertext cannot be filtered, ordered, grouped, or explicitly selected by generated query-builder methods; use a separately reviewed blind index when equality lookup is required. Raw SQL remains an explicit, non-transparent escape hatch. The builder’s encrypted, SecretString and skipped-column guards compare the (unqualified) column name ignoring ASCII case, like unquoted SQL identifiers, and pluck_string decrypts with the declared column name.
  • Generated secondary projections never carry #[orm(encrypted)] or #[orm(masked)] plaintext. to_json() (used for audit rows and committed ModelCommittedEvent/Redis orm:events:* payloads) omits #[orm(hidden)] fields and replaces encrypted and masked values with the fixed "***" marker. An update that changes only such a field still writes an audit row that records the field as "***" on both sides, and that revision cannot be restored. Scout documents omit hidden, encrypted and masked fields entirely. save_to_redis stores encrypted fields as the same table/column-bound envelope as the SQL column and get_from_redis decrypts them; hashes written by earlier versions with plaintext in those fields fail closed on read and must be rewritten. Masked and hidden values keep their database representation in Redis hashes and the query cache, like the SQL column. Audit rows, search documents and events written by earlier versions are not rewritten; purge or reindex them if they may contain plaintext.
  • SecretString never serializes its plaintext. Its SQLx codec encrypts and decrypts the column. Serialize emits an authenticated RULLST:v2 envelope under the configured key, bound to a serde-specific context (a SQL-column envelope is not accepted), and fails when no key is configured; Deserialize decrypts such an envelope through the current key or keyring and still accepts any other string as plaintext input. The envelope binds only the key, not a model, field or owner, so that Deserialize is for trusted stores: given client input, it would turn an envelope exposed for one record into another record’s secret. Fields filled from requests use #[serde(deserialize_with = "rullst_orm::privacy::deserialize_plaintext_secret")] (or deserialize_optional_plaintext_secret; v13 API), which reject any RULLST: value without decrypting it. Generated to_json() and search projections serialize it (also when nested) as "***", and SecretString/Option<SecretString> model fields are audited, excluded and change-tracked like #[orm(masked)] fields. A plain #[derive(Serialize)] on a model therefore emits the envelope; call reveal_audited() for deliberate exposure. Each write encrypts a SecretString column with a fresh nonce, so generated builder filters, ordering and grouping on it (including where_<column> helpers) fail with Validation instead of never matching, and pluck_string/pluck_i32 reject it rather than return envelopes. An explicit select of the column remains available because the codec decrypts it while decoding the model.

5.3. Generated Relationship Contract

  • An omitted foreign_key defaults to a lowercased model name plus _id: belongs_to reads <related model>_id from the declaring model (post_id for belongs_to = "Post"), while has_one/has_many and the owner side of belongs_to_many match <declaring model>_id on the other table. Models whose names are not single words usually need an explicit foreign_key. local_key (this model’s matched key, default id) applies to has_one, has_many, morph_one, morph_many and belongs_to_many; related_key (the related model’s key) applies to belongs_to, belongs_to_many and morph_to. Either option on another relation fails compilation instead of being ignored.
  • A relation key may be nullable (Option<T>, such as a nullable foreign key) on either model; generated loaders compare its inner value. A None key matches no row, like SQL NULL: the lazy loader returns None or an empty list without a query, an eager load assigns None (or Some(vec![]) for a to-many relation) to such a parent, and a related row whose key is None belongs to no parent. The morph_to id field stays non-optional.
  • SQLx models may declare morph_many, morph_one, and one or more explicit typed morph_to targets. A polymorphic relation requires morph_name = "..." (name remains a legacy alias).
  • morph_to fails macro expansion unless the source has a persisted bindable <morph_name>_id field and a persisted String discriminator named <morph_name>_type. foreign_key may override the ID field and related_key may override the target key.
  • The discriminator stores the Rust target model name. Lazy loading returns None for a different target; eager loading batches each declared target and never guesses an undeclared runtime type. Target models used in eager inverse loading must implement Clone.
  • Each eager load (has_many, has_one, belongs_to, morph_many, morph_one, morph_to and the related query of belongs_to_many) issues one query for the whole parent batch. When that query still carries the global row cap, it fetches one row beyond the cap and fails with a Validation error if the cap would truncate it, so no parent silently receives an empty or partial relation. A constrained eager load that sets an explicit smaller limit(n) (applied to the whole batch) or unsafe_unlimited() is honored as written. A to-many eager load (has_many, morph_many, belongs_to_many) assigns Some(vec![]) to a parent without related rows, so None always means “not loaded”.
  • An eager belongs_to_many load hands each parent its related rows in the order of the related query, so the order_by of with_<relation>_constrained applies as it does to the lazy <relation>_constrained loader; a related row linked by several pivot rows appears once per pivot row. Without an order_by the order is unspecified.
  • Every parent receives the related rows it shares with other parents (one belongs_to parent of many children, a non-unique local_key, or duplicated parent rows). The shared value is cloned for every such parent but the last; when the related model does not implement Clone, a shared row fails the load with a Validation error instead of leaving a parent empty.
  • The relation’s ownership predicate (foreign key, morph id/type pair or pivot key) forms its own AND group before a lazy <relation>_constrained or eager with_<relation>_constrained modifier runs, like the tenant and model-wide scopes, so an or_where in the modifier cannot return another parent’s rows.

5.4. Tenant Scope Contract

  • A SQLx model declaring #[orm(tenant_column = "tenant_id")] must have a persisted String, i32, f64, or bool tenant field. The derive rejects a missing or unsupported field type.
  • Generated queries fail closed when called outside with_tenant(...) and bind the active tenant inside the scope, converted to the tenant field’s type; a context of another type fails with Validation for reads and bulk deletes exactly as for mutations, instead of reaching a predicate that MySQL would compare numerically across tenants. Generated full/partial updates and instance delete/restore paths reject a model from another tenant.
  • The generated tenant predicate, the soft-delete filter and the chunk_by_id keyset name their column with the model’s table (<table>.<column>), so a join with a table that has the same columns, such as a tenant-scoped or soft-deletable belongs_to_many pivot, cannot make them ambiguous. A model-wide global_scope is application code and qualifies its own columns.
  • Model::unscoped() is the explicit global escape hatch. Deciding who may use it, deriving tenant identity from authenticated state, and database-level RLS remain host responsibilities.
  • SQLx builders keep offset-based chunk(...) for compatibility and expose fallible chunk_by_id(...)/chunk_by_id_with_tx(...) for stable ascending keyset traversal over the generated i32 primary key. This prevents deletes of processed rows from shifting later rows behind an offset; it is not a database-server cursor or a universal cross-shard snapshot. Without an order_by, chunk(...)/chunk_with_tx(...) order their pages by <table>.id, because SQL gives consecutive offset queries no stable order (a PostgreSQL synchronized scan, for example, can start mid-table). An explicit limit(n) caps the rows every traversal hands to its handler in total (the implicit global row cap does not), and an explicit offset(k) is where chunk(...) starts and how many rows chunk_by_id(...) skips before its first page.
  • A model delete with marked cascade_soft_delete has-one/has-many relations runs parent and direct-child mutations in one transaction. An existing explicit or task-scoped transaction is reused; otherwise delete() opens, commits, or rolls back its own transaction. The children are selected with the related model’s tenant scope but without its global_scope, so a child hidden by that model-wide filter is trashed with its parent too. Recursive descendant/cycle traversal remains a separate contract. The related model must itself use soft deletes (a deleted_at field or #[orm(soft_delete)]): a cascade into a model without them fails to compile (no method __rullst_cascade_soft_delete_with_tx, reported at the relation field) instead of permanently deleting the children of a restorable parent. A policy-protected child cannot be authorized in bulk, so its cascade fails closed with Error::Validation and rolls the parent delete back; delete such children individually first.
  • Generated #[orm(auditable)] instance save()/delete() operations (and restore()/force_delete(), recorded as restored/force_deleted) write their bounded audit entry through the same explicit, implicit, or task-scoped transaction as the model mutation. Audit write errors fail the mutation and roll its savepoint back; direct log_audit calls also honor a task-scoped transaction. Every recorded mutation requires an AuditContext naming a validated user, service, or system principal; the record also carries the optional correlation identifier and derives its typed tenant key from the active with_tenant(...) scope. The host remains responsible for deriving both contexts from authenticated authority rather than client assertions. An auditable save() reads the pre-image of its diff inside its savepoint with FOR UPDATE on PostgreSQL and MySQL/MariaDB, so a concurrent writer cannot change the row between that read and the UPDATE. The old side of a deleted, force_deleted or restored entry is likewise the persisted row read the same way just before the statement, not the caller’s possibly edited or stale handle. These reads, the revision-restore lookup, the row restore() re-reads and the belongs_to_many pivot query run within Orm::set_query_timeout, like the other generated statements.
  • create_audit_table creates the v2 schema and adds its columns to a legacy table without presenting legacy rows as v2 evidence. On MySQL/MariaDB a new table (or a newly added restore_patch column) stores old_values, new_values and restore_patch as LONGTEXT, because TEXT (64 KiB) cannot hold the bounded 5 MiB payloads. An existing table is never altered implicitly; while those columns are still TEXT, the call logs a warning naming the reviewed ALTER TABLE ... MODIFY ... LONGTEXT migration. JSON payloads are bounded and recursively mask sensitive names for create, update, and delete. The reverse patch records only the presence of a sensitive key, including a nested JSON key that an update adds or removes, and such an operation is not restorable. A reverse patch that would exceed its depth (64), operation (4,096) or 5 MiB size bound is omitted: the update and its audit diff are still recorded, and that revision cannot be restored. Audit/debug output does not expose principal, tenant, correlation, reason, or payload values.
  • An auditable model exposes restore_revision(audit_id, reason) and its caller-owned transaction variant. Only a bounded v2 update patch for the exact model, ID, and active tenant is eligible. The current row must still match the revision’s recorded post-state; PostgreSQL and MySQL/MariaDB also lock that row during restoration. A successful restore is a compensating audited update referencing the restored audit ID and reason. Legacy, create/delete, oversized, stale, malformed, cross-tenant, or redacted-field revisions fail closed. Bulk builders still do not synthesize per-row history, and durable external export remains an explicit outbox/application contract.

5.4.1. Transactional partial-update candidate (source admitted)

  • update_partial().save() and its new explicit save_with_tx(...) variant merge selected typed values into the current persisted row within a savepoint. PostgreSQL and MySQL lock that row with FOR UPDATE; SQLite retains its transaction/locking semantics and can reject contended read-to-write upgrades. Missing rows and cross-tenant model handles fail before mutation.
  • The merged model uses the normal generated save lifecycle, including policy, hooks, observers, encrypted fields, atomic audit entries and post-commit cache/Scout/observer effects. This is a logical partial change implemented through save(): a full-row UPDATE, or, for a model with a RowSnapshot field, an UPDATE of the patched columns whose value differs from the locked row (none: no UPDATE). Existing model hooks can transform that candidate under the normal save contract: the loaded row runs after_fetch before the patch is merged, as find() would return it, and restore_revision runs it on the restored row before saving. Both run inside the borrowed transaction, where reentrant ORM access fails closed.
  • The caller’s object is replaced with the fresh merged model only after the operation succeeds. A direct save waits for its transaction commit; an explicit or task-scoped save reflects the transaction’s tentative state, so the caller must discard/reload it if the enclosing transaction rolls back. A PostCommit failure does not undo a durable direct save. An ambiguous database commit error requires reconciliation rather than blind replay.
  • An empty builder remains a no-op. A failed policy, hook, SQL write or audit rolls back the operation’s savepoint and discards its pending effects; if that rollback fails too, the returned DatabaseError names both errors. This allows a managed outer transaction to catch that failure and continue. Strict post-commit timing requires Orm::transaction or the owned direct path; a raw SQLx transaction retains the documented observation limitation.
  • Unselected persisted values and relation fields in the caller’s object are refreshed from the database representation. Unsubmitted edits on that object do not become an implicit patch. This is not an expected-version comparison: callers needing conflict rejection must still supply their domain revision contract. Local SQLite/PostgreSQL/MySQL, cancellation, cache/Scout and extracted-facade journeys pass. Full hosted workspace/platform/package source admission passed in PR #236; final release admission remains separate.

5.5. Process-Local Post-Commit Contract

  • A nested Orm::transaction (called while another managed or task-scoped transaction is active on the same task) does not open a second pooled transaction. It opens a SQLx-tracked savepoint on the active transaction and passes the same shared handle to its closure. An Err rolls back only to that savepoint and is returned unchanged; a success releases it, so the nested work commits or rolls back with the outer transaction. Its after_commit callbacks are promoted to the outer commit boundary only on success. Dropping an unfinished nested future rolls its savepoint back. The returned future is Send for Send results and errors, so it can be nested in a transaction closure or spawned. Holding the shared handle’s lock across a nested call deadlocks, as it does for generated model methods.
  • Savepoints on one connection close in LIFO order. Sibling nested transactions started concurrently on one task take turns: each holds its level’s turn from SAVEPOINT until its release or rollback, while a transaction nested inside it uses its own level and never waits for it. Plain statements from a sibling future are not serialized and run inside whichever savepoint is open. A savepoint left open (a deeper level still open at release, a failed release/rollback, or a nested future cancelled while the connection was busy) never commits partial work: the enclosing savepoint rolls back instead of releasing, a managed transaction rolls back and returns an error instead of committing, and the ORM pool closes a connection returned while SQLx still reports an open transaction.
  • A generated stream() running on a managed or task-scoped transaction keeps that transaction locked between rows until it is consumed or dropped. While it is open, every other generated ORM call, Orm::transaction and Outbox::enqueue on that transaction fails with Validation instead of waiting for the lock (the query timeout does not cover that wait).
  • Orm::transaction and direct generated model save()/delete()/ restore()/force_delete() operations own a post-commit callback scope. after_commit callbacks registered within it run only after SQLx confirms commit and are discarded on rollback. When no managed transaction is active, after_commit executes immediately for an already committed/autocommit operation. #[rullst_orm::test] runs its body in such a scope too and never commits it, so the observers, cache, Redis and Scout effects of sandboxed writes are discarded with the rollback.
  • Generated observers retain synchronous lifecycle callbacks such as creating, created, and saved for mutation validation. The separate committed(ModelCommittedEvent) callback receives an owned snapshot after the managed commit; it omits hidden fields and carries "***" for encrypted and masked fields. Generated Redis invalidation/pub-sub and Scout projections use this same post-commit boundary.
  • Builders add with_trashed() (include trashed rows) and only_trashed() (only trashed rows). A model without soft deletes has no trashed rows, so its only_trashed() fails reads, counts, plucks and delete_all() with Validation instead of treating every live row as trashed; a directly set only_trashed field matches no row. with_trashed() changes nothing there.
  • Only delete(), restore() and force_delete() change the soft-delete marker of an existing row. Generated save() leaves that column out of its UPDATE (an INSERT still writes it), so a handle loaded before delete() cannot undelete the row and bypass can_restore, the restored audit and the restore observers. An auditable save copies the stored marker into the handle before recording its diff, and update_partial() rejects a soft-delete value with Validation.
  • force_delete() and restore() check the tenant and their policy (can_force_delete/can_restore) before the transaction, then run in a savepoint. force_delete() runs the before_delete/after_delete hooks, the deleting/deleted observers and the same post-commit cache invalidation, Redis deleted event, committed(Deleted) and Scout removal as delete(); it does not cascade to cascade_soft_delete relations. restore() re-reads the restored row, runs the updated/saved observers with it and registers the update effects of save() (cache invalidation, Redis updated/saved events, committed(Updated), Scout re-index). It runs no save hooks or saving/updating observers because it writes only the soft-delete column. Its UPDATE matches only a trashed row, so restoring a missing or live row is a no-op; likewise the soft-delete UPDATE of delete() matches only a live row, so deleting a trashed row again keeps its deletion time and fails like a missing row.
  • A save() update, delete() or force_delete() whose by-ID statement matches no row (for example a row deleted since its handle was loaded) fails with RecordNotFound, or with the tenant-scope Validation error on a tenant model, instead of reporting success; it rolls its savepoint back, so no post-mutation observer, hook, audit entry or post-commit effect runs. restore() of a missing row remains a no-op, also on tenant models. When the COMMIT of a direct save() (or the savepoint release of save_with_tx) fails, the handle’s id is restored like on any other failure, so a retry inserts again; an ambiguous commit error still needs reconciliation.
  • Savepoint-scoped generated saves/deletes and revision restores collect their callbacks in a nested scope. The callbacks are promoted to the enclosing commit boundary only after that savepoint succeeds, so catching a failed mutation cannot leak a later committed effect.
  • Every save_with_tx (and a save() joining a task-scoped transaction), not only an auditable one, runs in a savepoint of the caller’s transaction. A failed policy, hook, observer or SQL write rolls that savepoint back and restores the model’s id, so a caller that catches the error and commits does not persist the failed write. A direct save() owns its transaction and needs no savepoint.
  • Every queued callback is attempted. A failure is returned as PostCommit, whose contract explicitly means the database mutation is already durable. Applications must not retry the database mutation blindly from this error.
  • A caller-owned raw SQLx transaction passed to save_with_tx or delete_with_tx does not expose its later commit/rollback decision to the ORM. Use Orm::transaction for the strict process-local boundary.
  • These callbacks do not survive process failure and provide no retry, idempotency or cross-node delivery. Use the explicit durable outbox below for an irreversible or externally delivered effect; it is not enabled automatically by a generated observer.

5.6. Durable Transactional Outbox Contract

  • Outbox::enqueue accepts only a currently managed Orm::transaction and writes rullst_outbox through that same transaction (for a nested call, through the outer transaction’s savepoint). A domain rollback also removes the event. enqueue_with_tx provides the equivalent explicit path for a caller-owned SQLx transaction. No implicit independent commit is permitted.
  • (stream, event_key) is the database uniqueness boundary. Replaying the same key, exact event kind and JSON-equal payload (object key order is not content) returns the existing i64 identifier, including a key committed by a concurrent enqueue after the caller’s read snapshot (MySQL/MariaDB read it back with a locking FOR UPDATE read, as InnoDB’s default REPEATABLE READ would hide it from a plain SELECT); reusing the key with different content fails closed with an error that does not echo the stream or key. stream, event key, event kind and worker identifiers use a bounded ASCII grammar, and serialized payloads are limited to one MiB. Streams, event keys and claim tokens compare case-sensitively on every backend; MySQL/MariaDB declare those columns CHARACTER SET ascii COLLATE ascii_bin, and Outbox::install converts an existing MySQL/MariaDB table whose key columns use another collation.
  • PostgreSQL, MySQL/MariaDB and SQLite share the outbox state machine. A claim increments attempts and receives a random token plus a bounded lease. Only that token may acknowledge or fail the event; expiration permits another worker to reclaim it. Failure schedules a bounded retry or moves the event to dead_letter at the configured attempt limit, including a worker that dies while holding its final lease. claim_next, acknowledge and fail are independent lease operations that commit on their own connection; inside an active Orm::transaction they return Validation instead of committing before, and regardless of, the handler’s work. Acknowledge after that work has committed.
  • Delivery is at least once, not exactly once. A worker may perform its external effect and crash before acknowledgement, so consumers must use the stable stream/event key as their own idempotency key. Ordering across retries or concurrent workers is not guaranteed.
  • Outbox::install is an explicit setup/test convenience and never runs at startup. OutboxMigration puts the same schema under the built-in reviewed migration lifecycle. The ORM does not infer tenant authorization from stream, automatically serialize model observers, dispatch HTTP webhooks, purge delivered rows or promise cross-database transactions.

5.7. Generated Redis Query Cache Contract

  • The optional redis feature enables .remember(seconds) for generated SQLx reads. Orm::init_redis_with_namespace(url, application_namespace) is the recommended initializer when a Redis database is shared; the compatibility init_redis(url) initializer uses the literal namespace default.
  • Versioned SHA-256 cache keys bind the validated application namespace, a partition, table, generated SQL, and typed bindings. A model with a tenant_column partitions its entries by an opaque digest of the active tenant scope (global outside with_tenant); any other model keeps one global copy whatever tenant scope its reads run in. Raw tenant identifiers are not emitted in keys.
  • Generated reads always bypass Redis inside explicit and task-scoped database transactions, so cached state cannot replace the transaction’s own view. remember(0) is invalid. Outside transactions, explicitly requesting cache without initializing Redis fails closed as a configuration error; transport failures and corrupt cached values fail open to the authoritative database, whose result replaces the entry. Only a JSON array whose rows all decode and decrypt is a hit: null, an object or a row whose encrypted or SecretString field no longer decrypts (for example after key retirement) is a miss, not an empty result or an error.
  • Cache writes occur only after a successful database read and retain encrypted model fields as ciphertext; SecretString fields are cached as serde envelopes and decrypted on a cache hit, and a result that cannot be serialized (for example without an encryption key) is not cached. Each generated cache write also records its key in a per-namespace/tenant/table Redis sorted set, scored by the entry’s expiry time on the Redis server clock, in the same EVAL script; that script first prunes members whose entries already expired and extends the set’s TTL to the longest entry TTL, so the index holds only entries stored within the last TTL window. Generated model save()/delete()/restore()/force_delete() operations invalidate the table’s global partition and, for a tenant-scoped model, the partition of the tenant active at the write, only after commit (the tenant is captured when the callback is registered, so a with_tenant scope that ended inside the transaction closure still has its keys removed). An unscoped() read of a tenant-scoped model inside another tenant’s with_tenant scope is cached in that scope’s partition and is not refreshed by other tenants’ writes before its TTL. Invalidation prunes expired members without counting them, then removes each index’s live members in batches of 500 and UNLINKs their keys (at most 10,000 per write); they never SCAN the Redis keyspace, so their cost does not grow with unrelated keys in a shared database. Beyond the cap the write reports PostCommit, the remaining keys stay indexed for the next write, and the generated orm:events:* publication still happens. Keys are versioned (rullst:orm:cache:v4:); entries written by earlier versions are never read again and expire through their TTL, and during a rolling upgrade a write from an instance on the other version does not invalidate this version’s entries before their TTL. A transaction rollback preserves existing entries. The scripts address keys they were not passed and are therefore outside Redis Cluster, like the rest of this contract. Raw SQL, bulk builders, caller-owned raw transactions and writes from other processes cannot be inferred. Callers must retain a defensive TTL and treat Redis cluster/failover and durable invalidation delivery as separate application contracts.
  • Generated save_to_redis/get_from_redis/increment_redis_field model hashes use rullst:orm:hash:v1:<namespace>:<scope>:table-<digest>:<id> keys, binding the application namespace, an opaque digest of the model’s tenant (or global) and of the table. For a tenant model all three require with_tenant(...) with the tenant field’s type, save_to_redis rejects a handle from another tenant, get_from_redis rejects a decoded hash whose tenant differs and the tenant column cannot be incremented; another tenant simply misses. 12.0 and 12.1 stored every hash under the shared key orm:<table>:<id>. For a model without a tenant scope, get_from_redis reads that key when the namespaced hash is missing, and the next save_to_redis/increment_redis_field atomically moves it to the namespaced key (an existing namespaced hash wins and the stale legacy hash is removed), so data migrates lazily. A tenant model never reads, moves or deletes the legacy key, which every tenant shared; its hashes are migrated explicitly (see the Redis guide). The orm:events:* pub/sub channel names are unchanged and not namespaced, so subscribers keep working; use a dedicated Redis database when applications must not observe each other’s events.

5.8. Polyglot Persistence Boundary

  • Optional persistence adapters are disabled by default and selected one at a time with mongodb, duckdb, turso, surrealdb or qdrant; the umbrella crate exposes matching orm-* features. 13.0 removed the polyglot and orm-polyglot aggregates, which enabled every adapter at once, including DuckDB’s bundled C++ build.
  • Only the SQLx backends (SQLite, PostgreSQL, MySQL/MariaDB) integrate with the #[derive(Orm)] query builder, Schema, artisan!/db:*, Server::with_db, Nexus and Studio’s database pages, and every cargo rullst new starter accepts them. Turso/libSQL is the primary database of the Blank starter only: #[orm(backend = "turso")] derives the separate TursoModel API, migrations are SQL TursoMigrations, the starter answers db:migrate/db:rollback/db:status itself and TursoStore::transaction runs atomic statement batches; it integrates with none of the SQLx parts above. MongoDB, DuckDB, SurrealDB, Qdrant and Redis data structures are side stores with their own APIs and no transactions. cargo rullst new states these limits next to the Turso and side-store choices in the wizard, in --help and on the review/dry-run screen. Live CI contracts are matrix_postgres, matrix_mysql, matrix_mariadb, matrix_turso, matrix_mongodb, matrix_surrealdb, matrix_qdrant and matrix_redis_data; SQLite and DuckDB run in-process. The Database Support table is the user-facing statement of this boundary and must change with it.
  • DocumentRepository<T> provides create, find, replace, delete, and deterministic bounded listing. Collection names, document IDs, offsets and limits are validated before reaching a driver.
  • DocumentInventory<T> is the separate identifier-preserving extension used by recovery tooling. Its stable ascending pages avoid breaking existing third-party DocumentRepository implementations.
  • export_document_snapshot performs two matching bounded observations before sealing a versioned payload with AES-256-GCM. The authentication data binds the key-rotation ID, trusted application namespace and exact collection; decoding is capped at 64 MiB and 100,000 documents. Restoration accepts only an empty destination or an exact matching subset, never replaces or deletes, tolerates only an exact raced duplicate and verifies the complete final inventory. Applications must quiesce source/destination writers and durably store, rotate and protect keys/snapshots. Required destination schema must be provisioned first; driver/schema errors never become an implicit empty collection. The API is crash-resumable, not a cross-store transaction or managed backup service.
  • MongoDbStore<T> uses the official MongoDB Rust driver and stores the portable DocumentId as _id; portable models must not define _id.
  • DuckDbStore serializes access to its native connection and delegates every database operation to spawn_blocking. Dynamic values use prepared parameters, and callers must supply a QueryLimit before rows are materialized. Application-provided SQL text remains a trusted structural input.
  • TursoStore speaks the official Hrana HTTP v3 protocol directly for remote edge SQL. It uses positional typed parameters, conditional atomic batches, a 30-second request deadline, no redirects, a 16-MiB response bound, bounded row materialization, and ordered checksummed migrations. This avoids an unnecessary embedded/native SDK dependency while retaining conformance against the official libSQL server. Empty or mock_* endpoints use a one-connection SQLite fallback that exercises real SQL without pretending to be a remote replica. HTTPS/libsql:// is required outside explicitly enabled loopback development.
  • SurrealDbStore<T> uses the documented /key, /sql, /rpc and /gql HTTP endpoints with namespace/database headers (replace is a non-creating UPDATE through /rpc, and document IDs are always string keys), no redirects, bounded streaming responses, HTTPS by default, and redacted authentication configuration. GraphQuery::read_only accepts one MATCH query, rejects mutation tokens and caller-supplied limits, then appends a bounded limit.
  • This boundary does not turn every backend into SQL Active Record, perform cross-database transactions, provide an online-consistent snapshot, synchronize records between engines, or prove a third-party deployment. See the Polyglot Persistence guide.

5.9. Scout Search Projection Contract

  • Without a configured engine, Model::search(query) falls back to CAST(column AS TEXT) LIKE ? ESCAPE '!' over the persisted columns except #[orm(hidden)], #[orm(encrypted)], #[orm(masked)] and SecretString ones, so it cannot become a substring oracle for them. %, _ and ! in the query match literally, and the query uses the provider bounds (1,024 bytes, no control characters).
  • #[orm(searchable)] projects generated save/delete operations only after a managed relational commit. The table name is the Scout index, so a searchable model whose (explicit or default) table name does not start with a lowercase ASCII letter followed by lowercase letters, digits or underscores fails compilation instead of failing every projection. The indexed document omits #[orm(hidden)], #[orm(encrypted)] and #[orm(masked)] fields. Search adapter failures remain visible; a failed query is not silently treated as an empty result, and PostCommit means a projection failed after the database mutation became durable.
  • MockSearchEngine is deterministic and always available. The optional scout-http feature adds Meilisearch, Elasticsearch and Algolia. Empty or mock_* credentials select the mock; keyless live constructors accept only loopback HTTP, while remote/custom origins require HTTPS without URL credentials, redirects, paths, queries or fragments.
  • A provider answers one shared per-table index and returns at most scout::MAX_SEARCH_HITS (1,000) IDs before tenant, model-wide and soft-delete scopes filter them. For a model with any of those scopes, an answer of that size is treated as truncated: search() answers from the SQL fallback instead, so one tenant’s hits can never push another tenant’s matches out of the result. Below the cap the scoped provider IDs are used; engine-side tenant filtering is not provided.
  • Provider matches keep the provider’s ranking: search() orders them with an ORDER BY CASE <table>.id WHEN ? THEN 0 ... over the bound IDs, so get(), first(), paginate() and pluck_* follow relevance. An explicit order_by replaces that order, DISTINCT and GROUP BY statements omit it, and delete_all() does not treat it as a rejected order_by(). The SQL fallback has no relevance order.
  • Index names, positive IDs, object payloads, queries, response bytes and hit counts are bounded. Meilisearch/Algolia tasks use bounded polling; Elasticsearch requests use refresh=wait_for. Provider response bodies and credentials are not copied into transport errors.
  • The repository proves a real Meilisearch lifecycle in a digest-pinned container. Elasticsearch and Algolia protocol fixtures prove the documented HTTP shape and bounds, not hosted service operation, version-wide compatibility, ranking quality or cluster failover.
  • The generated hook remains process-local. Guaranteed crash recovery requires an application-versioned event in the transactional Outbox and an idempotent worker; the ORM cannot infer a safe event key or external retry policy from an arbitrary model save.

5.10. PostgreSQL pgvector Contract

  • The optional pgvector feature re-exports the SQLx-compatible Vector type. The supported execution profile combines it with strict-postgres; other SQLx backends do not pretend to implement PostgreSQL vector operators.
  • where_similar, L2, cosine and inner-product ordering validate column names, reject empty/non-finite vectors and invalid distances, and bind vector and distance values. ORDER BY bindings are assembled after WHERE bindings regardless of builder call order.
  • A digest-pinned PostgreSQL + pgvector container installs the extension, uses a typed vector model and proves L2 threshold/cosine ordering queries. The application still owns reviewed migrations, vector dimensions, embedding model compatibility, HNSW/IVFFlat index selection/tuning, tenant policy, context budgets, citations, ingestion/deletion and RAG evaluation.

5.11. Qdrant and Redis Specialized Store Contract

  • The optional qdrant feature exposes a separate VectorRepository rather than pretending Qdrant is SQL Active Record. Collection names, dimensions, vectors, cosine norm, point payloads, query limits and response bytes are bounded. The HTTP client rejects redirects and URL credentials, uses short connect/request deadlines, requires HTTPS outside loopback, redacts API keys, and never copies provider response bodies into errors.
  • QdrantConfig::new selects a deterministic in-process fallback for empty or mock_* endpoint/API-key values. unauthenticated_local is an explicit loopback-only path for self-hosted development. The supported live API is one unnamed dense cosine vector per numeric point with create, single-point upsert/delete and bounded nearest-neighbor query; named/sparse/multivectors, arbitrary filters, collection tuning and distributed topology are outside it.
  • The optional redis feature exposes RedisDataStore for explicitly namespaced Hash, Set and Sorted Set operations in addition to the generated query cache. Keys, fields, UTF-8 values/members, finite scores and scan/range materialization are bounded. Remote endpoints require rediss://; URL credentials are rejected, ACL credentials are redacted, operations have connection/response deadlines, and empty/mock_* credentials select a deterministic in-process fallback.
  • Digest-pinned Qdrant and Redis matrices prove their respective live lifecycles, including Redis namespace/structure separation. They do not prove hosted-provider availability, backups, cluster failover, tenant authorization, eviction policy, ANN quality, or cross-store transactions.

ORM Driver Selection

  • Orm::driver() reports the SQL dialect from the connected DSN scheme, case-insensitively: postgres/postgresql select PostgreSQL and mysql/mariadb select MySQL/MariaDB, so dialect-gated SQL such as FOR UPDATE row locks, quoting and outbox/migration DDL follows the server. Any other scheme keeps the SQLite dialect (an unknown scheme fails to connect under SQLx Any).
  • A DSN that still contains a bracketed template placeholder such as [your-database-id] or [YOUR-PASSWORD] fails Orm::init, init_with_options and init_with_replicas (primary or any replica) with Error::Internal before connecting. A bracketed IPv6 literal host (postgres://app@[2001:db8::10]:5432/app, optionally with a %25 zone identifier) is not a placeholder and is accepted by every entrypoint.
  • An in-memory SQLite DSN (sqlite::memory: or mode=memory) exists only while one of its connections is open, so its pool keeps one connection and never closes it for idleness or age (the Turso offline in-memory fallback does the same).
  • A SQLite file DSN without a mode parameter, or with mode=rwc, has its missing database file (and directory) created before connecting, at the percent-decoded path SQLx opens (John%20Doe is John Doe). An explicit mode=ro or mode=rw never creates one, so a wrong or unmounted path fails to open instead of becoming a new empty database.
  • Since 13.0.0-alpha.3 neither rullst-orm nor the rullst facade has a default feature (they were drivers-all, and orm, drivers-all and queue-sqlite), so a database-free application compiles no SQLx driver. A consumer selects strict-postgres, strict-mysql or strict-sqlite, each enabling only its own SQLx backend, or the drivers-all convenience feature for all three. On the Any pool, Orm::init, init_with_options and init_with_replicas turn SQLx’s no driver found for URL scheme error for a postgres/postgresql, mysql/mariadb or sqlite URL into Error::Validation naming the strict-* feature (or drivers-all) that adds the driver; other connection errors are unchanged. The strict pool’s existing precedence when multiple strict features are unified remains PostgreSQL, then MySQL, then SQLite.
  • Features are additive. Another dependency enabling ORM defaults, a SQLite queue, Turso’s offline SQLite transport or another SQLx driver can broaden the final graph. Core, Studio, Nexus and the facade propagate an explicitly selected backend when defaults are disabled. Studio’s SQLite queue is now explicitly optional; Studio’s defaults retain drivers-all and queue-sqlite, and Nexus’s drivers-all. Fifteen standalone normal/build graphs cover ORM/Core/Studio/Nexus/facade across all three SQLx backends. Capital/other optional capabilities may intentionally broaden those graphs. Turso enables SQLite for its offline contract.
  • A standalone consumer check must compile generated model/query/transaction code and inspect its normal/build graph for unrelated SQLx driver packages. Workspace all-feature or all-target checks cannot prove driver isolation. Applications previously disabling defaults without selecting a driver must choose a strict backend or explicitly restore drivers-all.
  • Native enum codecs follow the ORM’s selected drivers, not identically named features in the consuming application. ORM 12.1 opts its paired macro crate into runtime-driver-codecs and exports hidden, driver-gated codec helpers. The macro crate’s default expansion remains compatible with the all-driver 12.0 runtime; the opt-in helper expansion requires the matching 12.1 runtime. Standalone isolation checks include enum encoding/decoding as well as CRUD.
  • Optional generated APIs likewise follow the ORM’s features, never features of the consuming application. With rullst-orm/redis, models get .remember(...), commit-time query-cache invalidation, the orm:events:* publications and the Redis hash helpers; with rullst-orm/ai (which implies pgvector), embedding_for models get save_with_embedding. ORM 13 opts its macro crate into runtime-feature-gates and forwards both features, so the choice is made at expansion time. Without that opt-in (an older runtime resolving a newer macro crate) the legacy output keeps its application-evaluated #[cfg(feature = ...)] attributes.

5.12. ORM Telemetry Contract

  • Generated model/query entrypoints, transaction-aware variants, raw ORM queries and generated streams emit rullst.orm.query spans with only a static model, validated table and bounded operation name. SQL text, bindings, model values, DSNs and error strings are not fields of these Rullst-owned spans. The explicit debug query logger remains a separate opt-in surface.
  • Managed transactions emit begin and lifecycle spans. Their final outcome is one of the bounded commit/rollback states; transaction errors are returned to the caller rather than copied into telemetry. Generated stream spans are entered only while the stream is polled, so a tracing guard is never held across suspension.
  • Every pool constructed through Orm::init* emits SQLx pool-acquire timing at info level and promotes acquisitions slower than 500 ms to warnings. Primary and replica pools share this configuration. Direct pools constructed by the application are outside the contract.
  • These standard tracing spans/events are exported when the host enables the umbrella telemetry feature and initializes Core’s OpenTelemetry subscriber. The host still owns OTLP endpoint security, filters, sampling, retention and collector availability. SQLx or application logging configured separately may have its own statement-data policy.

💳 6. Billing & Payments (rullst-capital)

rullst-capital exposes provider-neutral billing contracts, a supported Stripe adapter and an experimental InfinitePay adapter. Applications integrate any other gateway by implementing the same traits in their own code; see Writing your own payment provider.

Every reviewed live adapter uses the same fail-closed outbound HTTP boundary: redirects and ambient proxy environment variables are disabled, connection and whole-request timeouts are finite, and a successful provider response is read only up to one MiB before JSON decoding. Failures expose a redacted typed ProviderFailure contract with permanent, transient, and rate-limited classes; only a bounded numeric Retry-After delta is retained. Raw response bodies, request URLs, credentials, and transport diagnostics are not included in the public error. Rullst deliberately does not retry billing mutations: callers may retry a transient or rate-limited result only when that exact operation has a persisted provider-forwarded idempotency key and a reconciliation policy. Returned checkout locations are accepted only as bounded, absolute, credential-free HTTPS URLs. Offline legacy checkout and portal fixtures use reserved mock.<provider>.invalid hosts and omit the customer email and return URL. Stripe’s documented opaque hosted-URL fragment is preserved; other adapters reject fragments. Provider/account sandbox acceptance remains external evidence.

6.1. Provider Architecture

Billing adapters implement BillingProvider; metered usage adapters implement MeteredBillingProvider with a provider-specific request type. Rullst ships two adapters: Stripe, the supported provider used by the generated billing flow, and InfinitePay, which is experimental until it is validated against a live account (its live plan-only checkout and live callbacks fail closed). The trait default for webhook verification mode fails closed, so a custom adapter must declare its verification mode before Rullst’s webhook middleware accepts it. Individual billing operations may still return Unsupported when a provider adapter has no reviewed implementation:

#![allow(unused)]
fn main() {
use rullst_capital::providers::stripe::StripeProvider;
use rullst_capital::providers::BillingProvider;

async fn create_checkout() -> Result<(), rullst_capital::CapitalError> {
let provider = StripeProvider::new("mock_api_key", "mock_webhook_secret");
let session = provider
    .create_checkout_session(
        "[email protected]",
        "price_monthly",
        "https://example.com/billing/complete",
    )
    .await?;
let _ = session;
Ok(())
}
}

#[derive(rullst::Billable)] is the umbrella convenience for named structs with an email: String field. It preserves generics; optional subscription_id: Option<String> and tier: Option<String> fields expose the corresponding helpers. An all-or-none grace_period_starts_at: Option<i64>/grace_period_ends_at: Option<i64> pair exposes a validated half-open window of at most 366 days. A provider-bound SubscriptionHandle<P> delegates cancellation and pausing; the explicit subscription_with path keeps static dispatch. These values do not infer or persist ownership, team membership, entitlement, currency, payment methods, usage or provider scheduling. Shared quota accounting is a separate explicit boundary described below.

The same derive inherits the bounded charge_with/charge helpers for an immediate off-session charge. A charge requires a positive integer amount in currency minor units (maximum eight digits), a three-letter currency, explicit provider customer and tokenized payment-method IDs, the model e-mail and an application-owned idempotency key of at most 255 bytes. BillingProvider::charge defaults to UnsupportedOperation; the reviewed live implementation is Stripe Payment Intents, which forwards the idempotency key, confirms off-session and fails closed on an amount/currency mismatch or a status other than succeeded or processing. Empty/mock_* Stripe credentials return a deterministic local receipt with the distinct non-success Mock status for exact retries. Rullst does not model raw payment credentials, prove that a stored method has a valid mandate, persist idempotency, grant an entitlement, reconcile webhooks or imply direct-charge parity across adapters.

Customer-bound Stripe Subscription Checkout

StripeCustomerRequest and StripeProvider::create_customer supply the preceding provider-customer operation. The immutable input binds an opaque local owner reference, a persisted retry key and optional bounded contact email. The response must be an undeleted customer with matching metadata, valid ID/time and the credential’s test/live mode when that mode is known. Mock receipts are separate from provider creation. Requests carry a versioned digest, explicit idempotency header and the pinned checkout API version. The host must persist provisioning intent before HTTP, bind the result to the authorized account/owner before checkout, and reconcile unknown outcomes; provider idempotency retention is not durable application state. Email is not used to find or establish ownership of an existing provider customer.

The additive StripeCheckoutRequest binds an existing provider customer, one server-owned recurring price, an opaque local owner reference, explicit HTTPS success/cancel URLs and an application-persisted idempotency key. The dedicated Stripe method sends that key and reference with the customer ID; it does not create or discover a customer by email. A bounded response must match the customer, owner reference, requested price/quantity, subscription mode and redirects before a checkout session is returned. Request/receipt debug output omits identifiers, URLs and keys. Empty/mock_* credentials produce an explicit deterministic mock, never payment evidence.

The application must commit customer/tenant ownership and an immutable attempt with its request digest before dispatch, retain account/test-live namespaces, and reconcile unknown outcomes. Provider idempotency has a finite retention window; repeating an expired key is not a durable deduplication guarantee. Session creation and return navigation never grant paid access. The legacy email-based trait method remains source-compatible; new generated Stripe flows use the dedicated customer-bound contract and durable persistence below.

StripeSubscriptionLookup and StripeProvider::retrieve_subscription provide an explicit read for reconciliation. The request binds subscription, persisted customer/local reference, expected price and test/live mode; the pinned-version response must match every binding and the bounded single-item state contract. StripeSubscriptionSnapshot preserves the exact provider status and separates retrieved state from deterministic non-entitled mocks. A retrieved snapshot is neither an event claim nor invoice-settlement evidence. Hosts must serialize reconciliation with their database update so a delayed earlier read cannot overwrite a newer state; fetching before an unrelated transaction does not establish event order. Account credential custody and durable intent remain application responsibilities.

Provider-Specific Metered Usage

MeteredBillingProvider deliberately uses an associated request type rather than pretending provider identities and retry semantics are interchangeable. StripeMeterEvent targets the current /v1/billing/meter_events API with the default stripe_customer_id and value payload mapping. It validates positive integer usage, the meter event name, customer, timestamp window and a forwarded identifier; the Stripe adapter sends that identifier as both event identity and HTTP idempotency key.

The adapter limits response JSON to one MiB, binds identity/quantity fields before returning UsageStatus::Accepted, redacts request/receipt identities from Debug, and returns deterministic Mock receipts for empty or mock_* API keys. Stripe documents only a rolling provider deduplication window. A custom adapter whose provider has no equivalent event-key field reports UsageDeduplication::ApplicationOutboxRequired; the application must then claim event_key durably before submission. Provider-account acceptance, durable outbox storage, retries, reconciliation and entitlement/quota policy remain application/release evidence. The legacy uniform BillingProvider::report_usage is compatibility-only and fails closed for live Stripe rather than guessing the required provider-specific identity.

Coupons and Relative Trial Extensions

CouponCode accepts at most 256 ASCII identifier bytes and redacts its value from Debug. The Stripe adapter uses discounts[0][coupon], requests expanded discount evidence and accepts only a response bound to both the subscription and requested coupon. InfinitePay and every adapter without a reviewed live contract return UnsupportedOperation rather than a false success. Empty or mock_* credentials retain the deterministic offline no-op required for local applications and tests.

TrialExtension resolves 1 to 730 whole days against a trusted clock. Billable and SubscriptionHandle expose the historical ergonomic extend_trial(15) meaning plus extend_trial_days_at for a stable persisted command clock and set_trial_end for explicit reconciliation. Stripe sends trial_end, caps the provider response and binds the returned subscription and expiration. The host must authorize the subscription owner, persist a stable command time before retry, serialize conflicting changes, reconcile signed webhooks and evaluate provider-specific billing-cycle effects. Live-account acceptance is release evidence, not inferred from protocol fixtures.

Server-side Plan Entitlements (v13 candidate)

The candidate rullst-capital::entitlements module supplies a read-time gate, separate from usage quotas. EntitlementScope binds an authenticated tenant to an existing BillingSubject; EntitlementPolicy binds one server-selected feature to at most 64 exact plan IDs, an explicit live/sandbox mode and a 1–300 second maximum reconciliation age. Only active subscriptions qualify; trial, past-due, canceled, unknown and mock states deny access. Validity has an exclusive end, and both future observations and clock rollback during a read are rejected. No serialized snapshot or browser field is an authorization token.

EntitlementStore is a static-dispatch trusted adapter contract. Every gate call reads it again and checks the returned tenant/subject binding, provenance, plan, status, expiration and trusted clock before and after the read. Store failures deny access. Hosts must supply authoritative current state and serialize reconciliation/revocation; this read gate does not make subsequent domain writes atomic or revoke an operation already authorized. Custom adapters and database backup/restore remain explicit application obligations.

The generated SaaS candidate consumes this gate at an authenticated billing report route. Its single-tenant scope is the configured Stripe account/mode inside the application’s database, never a request header or submitted owner. The existing durable billing intent and revision fence precede an actual ownership-bound provider refresh; the state is committed before authorization. It performs this refresh on every report, with a bounded deadline and no cached grant. The exact report-plan allowlist is separately configured on the server. Production requires live state; local sandbox state requires the explicit test profile, and offline credentials produce deterministic denial. Mutable CMS subscription projections, checkout redirects and unverified events cannot grant report access. Other providers need reviewed reconciliation adapters before this consumer can enable them. Existing profile/rights exports remain independent.

The local implementation and generated HTTP/database contracts exercise current state, account/owner isolation, revocation races, expiry, provider/store failures, mock denial and cancellation at the request deadline. Hosted source admission passed in PR #221; final release admission remains separate, and protocol fixtures do not establish live provider-account acceptance.

Shared Team and Workspace Quotas

BillingSubject identifies one authoritative user, team, workspace or trusted tenant as the owner of both the subscription and its shared counters. Billable::quota_request derives the limit from that owner’s tier policy instead of accepting it from an HTTP payload. A QuotaStore then performs an atomic, idempotent reservation before the application creates the resource.

InMemoryQuotaStore is the deterministic offline/process-local contract. The opt-in quota-sql feature supplies SqlQuotaStore for SQLite, PostgreSQL, MySQL and MariaDB. Its conditional counter update and unique event claim prevent concurrent members from exceeding the same limit. Exact retries return a replay grant without consuming or executing again; a key reused with different units or limit fails closed. Subjects, features and event keys are case-sensitive on every backend: MySQL/MariaDB key columns use ascii_bin, and the store refuses a legacy table whose key columns fold case until its documented migration runs. A replay proves only that the key is claimed, not that the guarded work finished: the claim may belong to an in-flight call that later fails and releases it, or to an abandoned call. QuotaGate::execute blocks the callback before an over-limit creation and compensates an ordinary callback error.

The convenience gate cannot make two unrelated storage systems atomic. A relational application that needs exact quota/resource atomicity must open a transaction from SqlQuotaStore::pool, call reserve_with_transaction, perform the domain insert through that transaction and commit once. Trusted middleware must establish membership and active tenant before constructing the subject. Plan/webhook reconciliation, migrations and custom/non-relational stores remain explicit application work.

6.2. Invoice Rendering

Invoice::generate_html remains the source-compatible escaped HTML renderer. Trusted paths use validate/try_generate_html: the legacy public f64 model accepts only bounded finite positive values with no more decimals than the currency’s ISO 4217 minor unit (none for JPY, three for KWD, two by default), converts them to 1 to 99,999,999 integer minor units of that currency, and requires the exact item sum. HTML and PDF amounts use the same number of decimals, so the minor units match a provider receipt for zero- and three-decimal currencies too.

The opt-in invoice-pdf feature adds bounded paginated A4 rendering. Its embedded Helvetica subset supports WinAnsi text; other scripts require a caller-supplied TTF/OTF of at most eight MiB containing every used glyph. PDF output is capped at sixteen MiB. Invoice::bind_succeeded_charge creates an immutable PaidInvoice only when a final, non-mock receipt exactly matches the recipient, minor-unit amount and currency. It derives a stable non-secret key from the invoice and provider evidence for use by an application outbox.

The downstream rullst-mail/capital-invoice feature converts that value into a pipeline-validated HTML message with the PDF attached and exposes one-call facade, tenant-aware or static-driver delivery. It does not infer a webhook event, atomically claim the delivery key, guarantee provider acceptance or promise exactly-once delivery. Applications processing retries or multiple instances must persist/claim the key and reconcile payment state durably before sending.

6.3. Webhook Signature Verification

  • The experimental InfinitePay adapter has a body-only verifier but no reviewed authentication and authoritative payment-lookup contract for the documented checkout callback. Real-secret verification and normalization return UnsupportedOperation; explicit mock-secret fixtures remain offline-only. A local HMAC fixture is not evidence that the provider signs that protocol. Enabling live processing requires order/merchant/amount binding and provider reconciliation first.
  • The additive StripeProvider::verify_subscription_event returns an immutable StripeSubscriptionEvent after the existing signature/freshness check and bounded subscription normalization. It retains event ID/type/API version, creation time, matching event/subscription test-live mode, optional connected account and opaque local owner reference, plus the original provider status. A versioned mutation digest excludes contact email, delivery signature and unrelated JSON fields; a separate digest binds the exact raw payload. Neither digest is encryption or a persisted replay claim. Mock verification is explicit in the result and require_real rejects it. The host must bind the endpoint’s provider account, mode, customer and owner, and atomically commit the event claim with domain writes. Event creation time is not a complete ordering/reconciliation protocol. The legacy WebhookEvent and BillingProvider contracts are unchanged.
  • Stripe subscription normalization requires an explicit supported lifecycle event and subscription object with bounded subscription/customer/price IDs. Its single-price v12 contract rejects multiple or truncated item lists and statuses outside Stripe’s subscription vocabulary. incomplete and incomplete_expired map to the legacy non-entitled Unpaid status; they must not be interpreted as proof of an unpaid invoice. Billing-period end is read from the single item for Basil payloads, or from the subscription on older payloads; conflicting period values are rejected. Email remains optional contact data. Event identity, ordering and atomic inbox processing remain separate requirements; an active subscription is not settlement evidence.
  • The Axum and opt-in Actix middleware adapters call one canonical bounded verifier before dispatch. Built-in provider adapters that accept live deliveries use provider-appropriate cryptographic verification; legacy parsers without a reviewed live contract fail closed. Equality checks for derived signatures are constant-time where applicable.
  • Timestamped protocols enforce a bounded freshness window. The default replay store is bounded and process-local and fails closed instead of evicting an unexpired proof when full. It holds at most 10,000 proofs for 24 hours each, so the default middleware answers 503 after about 10,000 verified deliveries per rolling day in one process; verify_webhook_with_state accepts a sized or shared store.
  • A custom adapter for a provider that signs only the body checks no delivery timestamp (provider retries can span hours or days). An exact captured body therefore verifies again after its replay claim expires (24 hours by default, at most 30 days) or, with the process-local store, after a restart. Hosts must record the provider’s event or subscription state durably and reject stale or repeated transitions instead of relying on the replay window alone.
  • The opt-in webhook-sql store shares bounded payload-digest or semantic-event claims across processes on SQLite, PostgreSQL, MySQL, and MariaDB. Its schema profile is immutable, claims serialize through one configuration lock, expiry uses the database transaction clock, and storage/configuration/capacity failures reject the request. MySQL/MariaDB claims use a plain INSERT whose duplicate-key error is a replay, so a caller-owned REPEATABLE READ transaction whose snapshot predates a concurrently committed claim cannot re-claim it.
  • SQL-backed middleware admission claims a payload before handler dispatch; it is replay protection, not an exactly-once delivery protocol. When billing correctness requires atomic domain mutation, the application must verify the provider payload, select the provider’s stable event ID, and call check_and_record_event_key_with_transaction through the same relational transaction as the mutation. Cross-system effects still require an outbox, idempotent consumers, and reconciliation.
  • The additive SqlStripeEventInbox under webhook-sql owns the relational transaction for a verified subscription event and its caller-supplied SQL mutation. An immutable scope binds application namespace, configured Stripe account, endpoint kind and test/live mode. Mock or mismatched events fail before database access. A per-scope configuration lock serializes admission; the stable event ID and versioned mutation digest distinguish an exact retry from conflicting content. A committed retry returns the retained outcome without invoking the mutation again. Domain errors and cancellation before commit roll back both changes; uncertain commit requires replaying the same event to discover the retained outcome. Capacity is immutable and bounded; records are never automatically evicted. The host owns schema migration, retention/reconciliation, account credential custody, authorized customer binding, ordering and an outbox for external effects. No SQL transaction can undo HTTP or other external effects performed by the callback. This API does not migrate the generated handler or grant access from subscription status.
  • Generated SQLx and Turso billing persists an opaque authenticated-owner binding, immutable customer/checkout intents and session IDs before redirect. Account identity is checked through Stripe and test/live namespaces are separate. Customer email never discovers or authorizes ownership. Stripe is the only generated provider: BILLING_PROVIDER accepts only stripe, and any other value is a configuration error before I/O. Real Stripe credentials require an explicit account, HTTPS return URL, webhook secret and price allowlist; mixed credentials remain unavailable. Customer and session creation reuse persisted keys for at most 23 hours. Older unknown outcomes use bounded read-only recovery; absence never permits another mutation. Completed and expired Checkout notifications and subscription lifecycle events trigger ownership-bound provider reads. A random database revision is committed before reading and compared atomically when committing the resulting subscription projection and durable event receipt. Concurrent newer reads fence earlier work; failed/uncertain commits remain retryable. An open checkout is retrieved and reused, and rejected/repeated requests do not consume a newly created-session quota. Existing subscriptions use an ID-bound customer portal. Provider-portal price changes must remain in the application’s allowed catalog. The application owns entitlement/settlement policy, inbox retention and capacity operations; a subscription state is not invoice settlement evidence. Existing application-owned code requires merging the generated modules and an additive migration; updating the package does not rewrite deployed controllers.
  • Hosted checkout forms require an explicit provider-specific CSP form-action origin on the document that submits the form, including its HTTP 303 handoff. The SaaS starter selects Stripe and adds only https://checkout.stripe.com to its generated policy; Core’s default remains form-action 'self'. Replacing Stripe with an application-owned adapter requires reviewing that exact checkout origin, including any merchant/custom domain. make:billing advises this integration without overwriting an existing application’s policy. This is a browser policy boundary, not provider URL authentication: real flows must independently validate HTTPS, exact host/port, absence of credentials and durable session, customer, tenant, product and test/live bindings before emitting a redirect. A real-browser POST/303 positive and disallowed-origin negative are required; HTTP-client status checks alone cannot establish this behavior.
  • Before the future generated live flow creates a checkout, resolve the authenticated owner’s persisted attempt. Resume only a provider-retrieved open session with all the bindings above; completed, expired and uncertain attempts need distinct handling. A business new-session quota applies only to new attempts, with a useful Retry-After on rejection; a separate request abuse limit may still protect retrieval. Concurrent clicks must converge on one durable intent/idempotency key. A disabled submit button is only feedback, never the duplicate-payment or ownership boundary.

6.4. Removed in v13

v13 narrows Capital to its provider-neutral base, Stripe and the experimental InfinitePay adapter. The Paddle, Lemon Squeezy, Polar, Razorpay, Mercado Pago, Alipay, Coinbase Commerce and PicPay adapters, the Wise payout adapter and the payout contracts (PayoutProvider, PayoutStatus, PayoutEvent and the global payout registry) were removed, together with the Lemon Squeezy usage types and the generated Paddle billing candidate. The NFS-e Nacional preparation pipeline (the fiscal module, the nfse and facade capital-nfse features, Invoice::to_dps and CapitalError::FiscalError) was also removed: it was never validated with a real municipality, and it may return as a separate product outside Rullst. The code remains in Git history. Applications implement other gateways with the traits above (Writing your own payment provider); the migration guide lists the affected APIs, and cargo rullst upgrade reports them as V13-CAPITAL-REMOVED.


🛡️ 7. Enterprise Security, RASP & Vault (rullst-security)

7.1. Rullst Vault (Authenticated Field Encryption)

  • Algorithm: AES-256-GCM with authenticated 96-bit random nonces and 128-bit authentication tags.
  • Envelope Format: RULLST:v2:<key_id>:<base64_nonce>:<base64_ciphertext_and_tag>.
  • Key Rotation: Built-in keyring support (decrypt_with_keyring) can read prior keys while new writes use the active key. Deployment coordination, re-encryption, key custody and retirement remain operator responsibilities.
  • ORM Configuration: RULLST_ENCRYPTION_KEY, RULLST_ENCRYPTION_KEY_ID, and RULLST_ENCRYPTION_KEYRING select the current and still-readable prior keys. Each is read from the process environment first and then from ./.env (v13), the precedence of APP_KEY and rullst::config::project_setting; the file never overrides or enters the process environment, and a malformed or unreadable .env fails the lookup with an error that names at most the entry number. Versioned envelopes name their key ID; pre-v12 SecretString ciphertext names none, so it is tried with the current key and then every keyring key (default first), and AES-GCM authentication accepts only the key that wrote it. Rullst does not provide key custody or automatic retirement.

7.2. Runtime Application Self-Protection (RASP)

  • Bounded Heuristic Inspector: ASCII case-insensitive signature matching covers selected SQL injection, traversal, SSRF, shell/JNDI patterns across URI, non-secret headers, and supported bounded textual/JSON bodies. Header values are decoded lossily in RASP and Core’s WAF, so an obs-text byte (0x80-0xFF) that hyper accepts cannot hide the rest of a value. Core’s WAF and this inspector classify body media types case-insensitively, including +json/+xml suffixes and every application/x-www-form-urlencoded-prefixed type, so a body that axum’s Json or Form extractor accepts is inspected. In User-Agent only, a complete PowerShell product token (PowerShell/<version> or WindowsPowerShell/<version>, delimited by whitespace) does not match the powershell keyword, so stock PowerShell HTTP clients are served; other occurrences and every other signature still match. The | sh signature matches only when sh ends at a word boundary and ; cat only when a path-like argument follows, so “| shopping” and “dogs; cat food” pass. Percent decoding and body/JSON inspection allocate; this control does not replace typed parsing, SQL binds, validation, authorization, or SSRF allowlists.
  • Login Guard Tarpit: record_login_failure returns progressive delay decisions and record_login_failure_and_wait applies them asynchronously; both share bounded, temporary in-memory jails keyed by a hashed identity. At max_identities, a new identity evicts the least recently failed counter and a new offender evicts the soonest-expiring jail; expired state is pruned in time order on every call, and jail telemetry is emitted only for a jail that was actually created.

7.3. MFA and Security Evidence Boundaries

  • TOTP enrollment: Secrets contain 160 bits derived from the OS RNG, verification accepts exactly six ASCII digits with constant-time comparison, and enrollment can emit an otpauth:// URI or bounded SVG QR. Secret custody, recovery workflow and durable rate limiting belong to the application. verify_totp_code is stateless and accepts steps -1/0/+1, so it does not reject a replayed code. verify_totp_step_after implements RFC 6238 section 5.2 by accepting only a step newer than the caller-supplied last accepted step; the application must persist the returned step per secret atomically before admitting the login.
  • Security CLI: CycloneDX generation, MSRV/tool diagnostics, unsafe/IDOR source heuristics, network observations and compliance evidence are bounded checks. They do not certify a deployment, prove absence of vulnerabilities or replace provider/CI evidence tied to an immutable SHA.

7.4. Bounded JSON Schema Enforcement

  • JsonSchemaPolicy::from_schema compiles an application-supplied JSON Schema 2020-12 document once; from_openapi_component selects one explicit components.schemas entry from OpenAPI 3.1. OpenAPI 3.0 is rejected because it is not the same schema dialect.
  • Construction caps serialized bytes, node count and depth, rejects non-local $ref/$dynamicRef, disables network/filesystem retrieval and selects the linear-time regex engine. The route-scoped Axum middleware first enforces the existing media-type, syntax, duplicate-key, payload-size and depth boundary, then returns 422 for schema mismatch without echoing values. A JSON media type is recognized in every spelling axum’s Json extractor accepts (any ASCII case, parameters, a json subtype or +json suffix).
  • The policy validates JSON bodies only. Authentication, authorization, ownership, business invariants and query/header/form parameters remain separate application boundaries.

7.5. Deterministic Threat Sentinel and Proof of Work

  • ThreatClassifier assesses a bounded aggregate window supplied by the host against transparent thresholds for credential stuffing, API scraping and distributed automation. It does not collect traffic, infer identity, use a model or attribute a botnet.
  • ProofOfWorkGate issues OS-random, HMAC-authenticated challenges bound to one canonical application subject. Tokens have bounded difficulty, TTL and cardinality; successful verification atomically consumes local state so only one concurrent verifier succeeds in the process.
  • Classification is evidence, not authorization. The host chooses whether and where to challenge, provides an accessible fallback, rate-limits issuance and owns trusted proxy/device policy. Replay state is process-local; distributed enforcement, durable telemetry and cross-process one-shot consumption require an application adapter.

7.6. Authenticated Local SIEM Journal

  • AuthenticatedSiemSpool is the opt-in authenticated counterpart to the compatible unsigned DurableSiemSpool. It normalizes each local v1 event, writes it synchronously under 16 MiB/4,096-record ceilings and authenticates sequence, key identifier, predecessor tag, payload length and exact payload with a domain-separated HMAC-SHA256 chain.
  • SiemKeyRing accepts one active write key and at most seven historical verification keys. Key identifiers are bounded, key material is consumed into zeroizing storage, and secret-bearing Debug output is redacted. A rotation reopens the spool with the new active key plus every still-needed historical key; silently missing or wrong keys fail closed.
  • This journal detects forged records, interior deletion/reordering and external length changes. Removal of a complete valid tail is indistinguishable from an earlier valid file unless the operator retains a trusted external checkpoint. Path/key custody, permissions, single-writer enforcement, rotation retirement, compaction, retention, backup, transport, retry, acknowledgement and dead-letter handling remain operator/application work.

7.7. Cryptography Providers (since 13.0.0-alpha.4)

  • TLS: aws-lc-rs is the rustls crypto provider of every TLS connection a Rullst crate makes (reqwest, SQLx, Redis, SMTP, MongoDB, OTLP). No Rullst crate enables rustls’ ring feature or a ring TLS feature of SQLx, MongoDB, lettre or testcontainers.
  • Primitives: ring stays a direct dependency for HMAC, digests, randomness and signatures, because it builds for wasm and edge targets.
  • Process default: Server start-up (Core orm, storage-s3 or telemetry), the OTLP exporter, Redis TLS connections and the standalone rullst-studio binary install aws-lc-rs as the rustls process default only when none is installed; an application’s provider installed first is kept. Installation never panics. See cryptography providers.

📡 8. IoT, Firmware Security & Protocol Frames (rullst-iot)

8.1. Ed25519 OTA Firmware Gate

  • Firmware Verification: Strict Ed25519 signature validation over a cryptographic manifest [target, version, rollback_counter, firmware_len, firmware_sha256].
  • Anti-Rollback Protection: Verification rejects any counter lower than or equal to the state loaded into the manager. The recommended RollbackCounterStore path additionally performs an exact compare-and-set and may report success only after a strictly increasing value is durably committed across reset. A retry of the same verified manifest after an ambiguous store failure completes only when the store reports, and a fresh load confirms, exactly that manifest’s counter; an equal stored counter without such an earlier attempt remains a conflict. Atomicity, integrity, wear-leveling and power-loss behavior are obligations of the caller’s platform adapter and require hardware-specific evidence.
  • Commit Invariant: In-memory partition selection and store-backed counter commit are blocked until full cryptographic verification succeeds. verified_target_partition exposes the inactive bank (opposite current_partition) for platform flash/read-back before commit. The v13 new_with_running_partition constructor takes the bank the bootloader started, while the older constructors assume PartitionA and require platform code to set current_partition; a commit selects the other bank for the next boot but does not change current_partition, so an update verified again before reboot never targets the running bank. The compatibility commit_verified_update path is process-local and does not claim persistence, flash or bootloader control.

8.2. Embedded Sensor Frames (#![no_std])

  • rullst-iot core models compile under bare-metal #![no_std] targets (STM32, ESP32-C3, Cortex-M).
  • cargo rullst make:iot <DeviceName> generates and registers a local telemetry module, enables the umbrella iot feature and refuses unsafe names or collisions. It does not install firmware, a HAL, MQTT or CoAP.
  • IotDashboard renders an escaped HTML snapshot. It does not infer online state or provide a live device connection.
  • MqttPublish encodes one bounded MQTT 5 PUBLISH packet with validated topic, minimal Remaining Length, QoS/packet-identifier invariants and an empty property section. CoapRequest encodes bounded RFC 7252 base requests with a token, ordered URI-Path (1-255 bytes per segment, never . or ..)/Content-Format options and a non-empty payload marker. Both compile under no_std; neither opens a socket or owns protocol session state.
  • 🔵 [Roadmap] MQTT/CoAP Transport: Async connections, TLS/DTLS, broker negotiation, acknowledgements, retransmission/congestion control, block-wise transfer and interoperability matrices remain separate integration work.

🤖 9. AI Agent & LLM Orchestration (rullst-ai)

9.1. Guarded AI Client

  • AiClient::auto() and Nexus share AutoAiConfig. Resolution uses one snapshot in OpenAI/custom, Anthropic, Gemini, DeepSeek, Groq, Ollama order. Empty environment values are absent; explicit mocks remain offline. Groq requires GROQ_API_KEY plus GROQ_MODEL; a custom OPENAI_BASE_URL requires OPENAI_API_KEY plus OPENAI_MODEL and uses the HTTPS chat-only compatible adapter. Configuration indicators do not attest network/account health.

  • Provider-agnostic interface for Google Gemini, OpenAI, Anthropic Claude, DeepSeek, Ollama, and explicit OpenAI-compatible endpoints. The compatible adapter is chat-only by default; applications declare optional request shapes for one exact model. Loopback may be unauthenticated, cloud requires HTTPS/Bearer, and unrelated protocols implement the public AiProvider boundary rather than passing through arbitrary HTTP.

  • Prompt Injection Firewall: Real-time token heuristics intercepting prompt exfiltration, instruction overrides (DAN mode), and delimiter injection attacks.

  • Automated PII Masking: Scrubs check-digit-valid CPF/CNPJ numbers (canonical formatted or unformatted), card-like digit runs and email usernames prior to outbound LLM dispatch. Alphanumeric CNPJs and other identifiers are not recognized.

  • Token usage (v13): chat_with_usage/send_with_usage return a ChatCompletion with optional TokenUsage parsed only from documented provider fields (OpenAI/DeepSeek usage, Anthropic usage, Gemini usageMetadata, Ollama eval counts, compatible servers’ usage). Streams report it through AiStreamSink::usage and StreamSummary::usage(); compatible configurations request it only with with_stream_usage(). Missing counts are None, never estimated.

9.2. Bounded Streaming and Cancellation

  • StreamingAiClient<P> preserves static dispatch, reapplies the mandatory input guardrails and enforces at most 4,096 non-empty chunks, 64 KiB per chunk and 2 MiB aggregate output independently of the provider.
  • An OpenAI-compatible configuration may explicitly declare SSE streaming. The transport requires text/event-stream, bounded raw bytes, supported chat deltas and [DONE]; malformed, truncated or oversized streams fail closed.
  • Anthropic (Messages SSE, requiring message_stop) and Gemini (streamGenerateContent?alt=sse, requiring a final finishReason) stream natively (v13) under the same bounds and guardrails; unknown Anthropic events are ignored and error events expose only their type. Truncated or withheld replies (Anthropic max_tokens/model_context_window_exceeded/refusal, Gemini MAX_TOKENS or safety-class reasons) fail in streaming and non-streaming paths alike.
  • AiCancellation races the initial request and every streamed body read. It drops local transport work but does not prove upstream cancellation or stop provider billing. Non-compatible protocols and ordinary non-streaming calls retain deadline/drop semantics.

9.3. Bounded Tenant-Aware RAG

  • RagPipeline::answer requires a trusted Core TenantContext and composes guarded embedding, a static-dispatch application RagRetriever, bounded context selection, guarded generation, source metadata, and one required terminal RagAuditSink event.
  • Retrieved documents carry the trusted tenant tag. The pipeline rejects mismatches, over-return, injection heuristics, empty context, non-finite embeddings, and unavailable mandatory audit evidence rather than silently generating an ungrounded response. A guardrail block of the assembled prompt is audited as ContextRejected before any provider call.
  • Context limits count Unicode scalar values per document and in total. The audit event omits raw question, context, embeddings, provider bodies, and answer; its SHA-256 query digest is correlation metadata, not encryption.
  • InMemoryRagRetriever and InMemoryRagAuditTrail are bounded process-local development/test implementations. DurableRagAuditTrail and DurableToolAuditTrail add bounded, synchronously persisted, versioned local evidence with restart validation and fail-closed corruption/quota handling. They are single-process writers; their SHA-256 frames detect corruption but do not authenticate events. Production hosts own authoritative tenant and ownership predicates, durable/external vector adapters, ingestion/deletion, model/vector compatibility, output policy, directory permissions, rotation/retention/backup, external audit delivery, tuning, evaluation and recovery.

9.4. Bounded Conversational Memory

  • StatefulChat<M> uses static dispatch over ChatMemory, requires a trusted TenantContext and validated ConversationId, loads only the configured even number of recent messages, applies the guarded AiClient, and persists the user/assistant exchange only after generation succeeds. A response that the guardrail would block on replay, or that is empty or larger than 64 KiB, is rejected as a generation failure and not persisted.
  • InMemoryChatMemory is deterministic, tenant-partitioned, cardinality-bound, and intended for tests/local use. The opt-in sql-memory adapter supports SQLite, PostgreSQL, MySQL, and MariaDB through a dedicated SQLx Any pool.
  • The SQL adapter advances an even conversation revision and inserts both messages in the same transaction. A compare-and-swap predicate rejects stale cross-process writers; Rullst deliberately does not retry the provider call. History reads bind the tenant/conversation and never include rows newer than the revision observed by that read. Tenant and conversation IDs compare case-sensitively on every backend: MySQL/MariaDB tables declare the key columns CHARACTER SET ascii COLLATE ascii_bin, and each tenant-scoped MySQL/MariaDB statement also compares the key byte-exactly. prepare_schema never alters an existing table; a legacy case-insensitive table fails closed for IDs that differ only by case (InvalidConfiguration on ensure_conversation) until the operator applies the documented migration.
  • Message text is not encrypted by this adapter. Authenticated conversation ownership within a tenant, retention/erasure, provider audit, backups, migration governance, and user-facing conflict retry remain host policy. The generated Turso/custom-model scaffold is a separate application-owned path.

9.5. Authenticated Audit Export

  • AuditDeliveryClient exports an application-minimized serializable event in a versioned JSON envelope of at most 16 KiB. Cloud endpoints require HTTPS; local HTTP(S) requires a literal loopback IP. Redirects and ambient proxies are disabled.
  • HMAC-SHA256 covers a domain separator, key ID, Unix-millisecond timestamp and the exact body. A caller-supplied event ID remains unchanged across one to five attempts. Only transport/deadline errors, HTTP 429 and HTTP 5xx are retryable, and a closed acknowledgement of at most 8 KiB must bind that ID. AiCancellation races request, body and retry waits. Empty or mock_* keys select deterministic offline behavior.
  • A timeout may follow remote acceptance. The receiver therefore owns signature and freshness verification, event-ID deduplication, authorization, persistence, retention, key distribution/rotation and operational availability. The client does not minimize arbitrary event values or provide a durable outbox/SIEM service.

9.6. Bounded Adaptive Evaluation

  • AdaptiveAiEvaluator<P> keeps static provider dispatch and reapplies the mandatory prompt guardrail on every strategy-generated prompt. A scenario is capped at 32 turns, 16 KiB per prompt and 2 MiB per response, with an independent per-turn deadline and AiCancellation raced against each call.
  • AiEvaluationStrategy temporarily receives the bounded response or a low-cardinality guardrail/provider/deadline outcome and explicitly chooses pass, fail, inconclusive or a next prompt. The synchronous strategy is application code and must not persist/log model text without policy.
  • The version-1 JSON report records caller-supplied suite and exact model/configuration subject labels, provider name, terminal code and only per-turn byte counts/outcomes. It retains no prompt, response or provider error. Subject labels are assertions, not automatic model discovery.
  • Deterministic repository fixtures prove bounds, feedback, redaction, cancellation and status semantics. Operators still version domain corpora, run them against every exact live model/configuration and review results; a pass is not universal safety, groundedness or jailbreak-resistance evidence.

📊 10. Control Center & Admin Interfaces (rullst-studio & rullst-nexus)

10.1. Rullst Studio (http://127.0.0.1:5555)

  • Local-first developer dashboard with a server-rendered dark interface; browser assets and final page policy remain deployment concerns.
  • Process observations sourced from RadarSnapshot::collect() and explicitly supplied local collectors; unsupported values remain unavailable.
  • Generated applications start the standalone Studio only in debug builds and bind it to loopback. Its local capability verifies the direct loopback peer, accepts only a local Host authority, requires same-origin Origin on unsafe methods, and rejects missing origins on mutations. Responses carry Referrer-Policy: same-origin so that browser form posts keep the real origin; Origin: null is accepted only with Sec-Fetch-Site: same-origin, never alone or from a same-site document. This is a local DNS-rebinding/CSRF boundary, not production authentication.
  • Queue, revenue, security and telemetry pages report only values supplied by their configured process-local source. The queue page labels its status counts as covering only its 50 most recent records; the pending count and purges cover the whole queue. Its previews are cut to 256 payload and 512 error characters. The listing uses QueueDriver::list_job_previews with a 2 KiB budget per payload and error, which SQLite and Redis apply in the store; a custom driver without its own implementation still loads complete records through the list_all_jobs projection. Unsupported driver operations and disconnected integrations remain errors or Unavailable. The standalone migration surface provides CLI guidance and returns 501 from legacy mutation handlers because no migration/seeder registry is installed.
  • Studio’s database views use the application’s ORM pool or, when none exists, the shared Server/Artisan database URL resolver (section 4.1). There is no implicit SQLite fallback; resolution errors never echo configuration content.
  • The database browser accepts a deliberately narrow ASCII SQL-identifier boundary. Reads are bounded: a page loads at most 25 rows, ordered by the complete primary key (otherwise by every selected column) so that pages neither repeat nor skip rows, the database cuts each cell’s text to 256 characters (key columns keep up to 16 KiB so that rows remain addressable; a longer key makes the row read-only) and search terms are limited to 256 bytes. Search matches the displayed columns (at most 256), and the record count uses the same predicate. Writes require the crate-private proof inserted by the verified local middleware, database-inspected table/column/complete-PK metadata, a 64 KiB request limit, primitive typed binds and exactly one affected row. A row offers only the actions whose form (for Edit, with a maximum-size value) fits that limit, and an empty text key is addressable. A primary-key column outside the identifier boundary or the 256-column cap makes the table read-only instead of shortening the key, as does a floating-point key column, whose rendered text is rounded. Rows whose key is NULL, is not decodable as text or contains a line break or NUL (which browsers rewrite in form values) offer no actions. Each write runs in a transaction that commits only for exactly one affected row; zero or several rows are rolled back and reported as 404/409 (engines without transactions, such as MySQL MyISAM, cannot roll back). Primary keys/backend-specific values (including MySQL/MariaDB unsigned or zero-filled integers) are read-only, while delete requires DELETE <table>. SQLite, PostgreSQL, MySQL and MariaDB run separate mutation contracts; PostgreSQL runs under both the default sqlx::Any build, where Studio renumbers bind markers and casts information-schema identifiers to VARCHAR, and strict-postgres. On PostgreSQL Studio browses the public schema and names it in every data statement, so search_path cannot resolve a table to a same-named table in another schema. This is not application authorization, tenant scoping, audit, rollback or shared-production administration. The ER diagram inspects the same relational backends with bound lookup values and strict normalized Mermaid identifiers, lists each column once and pairs composite foreign-key columns by key position. The read-only API reference requires an application-supplied OpenApi.
  • Request SSE records method, URI, status, and latency without bodies or headers. Environment values are redacted by default; an allowlisted value is also redacted when it carries URL user information, a bearer token or a secret-named assignment. The typed config projection never renders connection URLs, filesystem paths, cookies, tokens, or credentials. A successful Studio database-flag mutation invalidates warm DbFeatureDriver caches in the same process; direct writers and other processes remain subject to TTL unless the host distributes invalidation. The driver also caches missing flags and failed or two-second-timed-out lookups for its TTL, serves the last value read after a failed refresh and bounds its cache to 4,096 flag names. SQLite removes completed jobs by default. An explicit 1–100,000-row history policy retains and atomically prunes real completion records for Studio, with a separate purge; retained payload access and lifecycle belong to the host. Redis lists at most 1,000 failed, dead-letter, processing, pending and scheduled jobs, retries failed jobs and purges failures; custom queue inspection remains capability-specific. Queue retry/purge and feature-flag toggles require the same crate-private verified-local marker as database writes, so their raw routers return 403 outside the local boundary. Safe GET views perform no schema or data writes; the feature-flag page reports a missing rullst_feature_flags table instead of creating it; other query failures (connection, permission, column types) are reported without migration guidance.
  • Studio::with_cache is an explicit metadata-only diagnostic capability. The memory and Redis cache drivers return at most 200 sorted entries containing logical key, UTF-8 value byte length and remaining TTL; custom drivers return InspectionUnsupported by default. Studio displays at most 100 keyed opaque identifiers and never cache values or exact logical keys. Individual invalidation requires its unforgeable verified-local marker and a fresh process-bound HMAC token. The page has no bulk flush operation.
  • Distributed trace producers use separately mounted, push-only routers; each endpoint binds one exact producer name to one key and multiple producers use separate endpoints/keys over the same shared store. A v1 batch is at most 128 KiB and 128 spans, carries W3C-compatible IDs, a bounded source/operation/kind/timing/status set, and accepts no attributes, SQL, bindings, headers, bodies or error strings. HMAC-SHA256 authenticates the exact body and source/timestamp/nonce headers; a 60-second window and bounded atomic nonce cache reject replay, while the shared in-process store is capacity-bound and idempotent by trace/span ID. The local viewer reports a fixed 100 ms slow-query signal and a three-equal-label possible-N+1 heuristic. This is not an OTLP collector, durable backend, secret manager, remote viewer/login, or proof that a repeated operation is defective. TLS, network policy, key distribution/rotation, clock synchronization, producer label redaction, retention and availability belong to the deployment.
  • Exposing Studio beyond the developer machine requires an explicit authenticated network boundary owned by the application; no environment variable silently converts the local server into a production admin surface.

10.1.1. Production monitoring endpoint (v13, 13.0.0-alpha.3)

Phase 1 of the Studio production monitor: a read-only machine contract served by the application, which a later Studio “remote projects” mode reads. Guide: production monitoring; threat model TM-STUDIO-2.

  • Mounting. Server::with_monitor(rullst_core::monitor::Monitor) mounts GET /_rullst/monitor/v1 (Core’s reserved /_rullst/* namespace, schema major version in the path) and a recording layer outside the framework layers, so their refusals are counted. At start-up the server adds the comma-separated tokens of RULLST_MONITOR_TOKENS (process environment, then .env) to Monitor::with_token values. With no token and no MonitorAuthorizer, nothing is mounted (404) and request handling is unchanged. Invalid tokens (fewer than 43 base64/base64url or 64 hexadecimal characters, other characters, more than 512 characters, fewer than ten distinct characters, placeholder words, more than eight tokens), invalid application labels (1–128 bytes, no control characters) and invalid queue names (1–64 of [A-Za-z0-9._-], unique, at most 16) stop start-up with ServerError::Configuration, whose message names a position, never a token. The hot-reload development server does not mount it. Since 13.0.0-alpha.3 the src/main.rs of the Blank (web and JSON API) and SaaS starters (and of the LMS example) carries the with_monitor call commented out, and their .env/.env.example list # RULLST_MONITOR_TOKENS= commented out with a generation hint; nothing is enabled by default.
  • Admission. In order: a request with Origin, or Sec-Fetch-Site cross-site/same-site, gets 403; in staging and production a request without TLS evidence gets 426 (evidence: https reported by a trusted proxy with trust_forwarded_proto, the application-inserted MonitorVerifiedTls marker, or, with Monitor::allow_loopback_plaintext, a direct loopback peer with a loopback Host and no forwarding header); a per-client budget of 30 requests refilled at one per second, then a lockout after 10 presented invalid Bearer values refilled at one per 90 seconds, both 429 with Retry-After and keyed per IPv4 address or IPv6 /64 after trusted-proxy resolution. A locked client’s credentials are not evaluated. Then Authorization: Bearer is compared with every token’s SHA-256 digest in constant time; otherwise the MonitorAuthorizer answers Allow (200), SignIn (401), Deny (403) or an error (503, reason logged). Missing and invalid credentials get one empty 401 with WWW-Authenticate: Bearer realm="rullst-monitor". Every response of the route carries Cache-Control: no-store and X-Content-Type-Options: nosniff; other methods get 405.
  • Document (rullst.monitor.v1). schema, generated_at, application (name, version or null), framework, uptime_seconds, process (radar), http (window_seconds, route_limit, routes_overflowed, requests_total, status_total, window, routes), database (observed with queries_total and n_plus_one, or unavailable with a fixed reason) and queues. A test pins this exact field set. Route keys are the matched route pattern (Axum’s nested tail shown as {*rest}, control characters replaced, at most 256 bytes) or (unmatched), and a method from the standard set or OTHER; at most 256 pairs, then one */(other) bucket. Windows are five one-minute slots with a 17-bucket latency histogram (0.1 ms to 10 s and an open bucket); a percentile is its bucket’s upper bound capped at the window maximum. N+1 findings use the rullst_core::query_patterns rule on ORM label fingerprints attributed to a request scope, at most 64 entries. Queue pending comes from the store within 250 ms per queue (timeout/driver_error otherwise); in-flight, completed, failed, retried and stalled-recovered counts cover this process’s workers. Raw paths, query strings, headers, cookies, addresses, user identifiers, bodies, SQL text or values, error messages, environment and configuration values are never recorded.
  • Cost and scope. State is process-local and resets with the process. The passive ORM query layer is part of the framework subscriber in every build and attributes operations only inside a recording scope. A release-build measurement adds about 0.6 µs per request (guide, “Overhead”).

10.1.2. Studio remote projects (v13, 13.0.0-alpha.3)

Phase 2 of the production monitor; threat model TM-STUDIO-3; guide: production monitoring.

  • Process. rullst_studio::remote::run_remote_studio (the rullst-studio binary, also started by cargo rullst studio --remote) exists only with the non-default remote feature of rullst-studio (which adds reqwest, ring and SQLx’s SQLite driver), so applications that mount Studio in development build none of it. It listens on 127.0.0.1 only (default port 5556) and needs no application process. Its router applies Studio’s loopback boundary (loopback peer and Host, same-origin Origin on unsafe methods, crate-private marker) in release builds too, and mounts only /studio (projects), /studio/radar, /studio/routes, /studio/queues, /studio/queries and the assets; local surfaces (data browser, .env, AI, API, migrations, jobs, cache, features, requests) answer 404. Add and remove forms also require a random per-process form token compared in constant time.
  • Storage. projects.json in $XDG_CONFIG_HOME/~/.config/%APPDATA% under rullst/studio, and projects.key (32 random bytes) under the data directory ($XDG_DATA_HOME/~/.local/share/%LOCALAPPDATA%). Files are written atomically, owner-only (0600 in 0700 directories on Unix), read with a size bound, never through a symlink; unknown fields are rejected. At most 32 projects; names are 1–48 of [A-Za-z0-9._-] starting with a letter or digit. Tokens are sealed with AES-256-GCM (random 96-bit nonce) with the project name and normalized base URL as associated data; a token that does not decrypt is reported and never sent.
  • Requests. Base URLs are https://, or http:// to a loopback host, without user information, query or fragment. Studio sends GET <base>_rullst/monitor/v1 with Authorization: Bearer, follows no redirect, ignores proxy variables, validates certificates, and bounds each read to 1 MiB, 5 s total and 3 s to connect. It accepts only 200, a JSON content type and schema: "rullst.monitor.v1" with bounded lists (512 routes, 128 findings, 32 queues) and labels (512 characters); unknown fields are ignored. Failures are fixed statuses; every remote value is HTML-escaped.
  • History and alerts (phase 3). Projects marked watched are read every 60 s (clamped to 15 s–1 h) while the process runs. Each read stores one row of aggregates (reachable, requests per second, window requests, 5xx rate, p95, total pending and failed queue jobs) in history.sqlite in the data directory (created 0600, directory 0700); every write deletes rows older than 7 days and beyond 10,080 per project, and keeps the newest 500 alerts. Rules per project (unreachable; 5xx rate, needing 20 window requests; p95; pending jobs; failures between two polls, ignoring counter resets) fire at most once per cool-down (15 minutes, 1–1,440) for each rule, with messages built from fixed wording, the project name and numbers. /studio/history charts the samples as inline SVG; /studio/alerts lists alerts, sets each project’s watch flag, rules and webhook (same form token) and acknowledges alerts. A webhook URL must be https:// without user information or fragment, is sealed like a token and is never displayed; each alert is posted once as JSON (source, project, rule, message, at) without redirects or proxies within 5 s, and the fixed outcome is stored on the alert.

10.2. Rullst Nexus (/nexus)

  • Auto-generated CMS with dynamic CRUD operations and AI Admin Assistant.
  • Security Default: Fail-closed by design; requires explicit authentication middleware and RBAC role validation (admin) on all mutating endpoints.
  • Generated applications may use NexusAuthPolicy::local_development_or_basic_from_env(): debug builds accept only a peer address verified as loopback through ConnectInfo (IPv4-mapped peers are canonicalized), a local Host and, for unsafe methods, a same-origin Origin or Origin: null with Sec-Fetch-Site: same-origin (local responses default to Referrer-Policy: same-origin), while release builds require validated Basic Auth credentials from the process environment, then ./.env (config::project_setting; an unreadable or malformed .env is NexusBuildError::InvalidDotenv). Missing peer metadata is denied, and an environment mode flag cannot enable unauthenticated release access.
  • NexusAuthPolicy::application(authorizer) (v13) lets the application’s own sign-in decide: Nexus calls the application’s NexusAuthorizer with the request’s method, URI, headers and extensions on every request, before any data is read or rendered, so a revoked session or permission takes effect on the next request. NexusAccess::Allow { actor_id } admits the request under that audit actor (1 to 255 bytes, unpadded, without control characters; any other value answers 503); SignIn redirects a page request (GET/HEAD without HX-Request) to the login page with 303 and answers fragments and other methods 401 with HX-Redirect, which htmx and nexus.js follow; Deny answers 403; an authorizer error answers 503. The login page is /login or NexusApplicationAuth::with_login_path, which must be a local path of at most 512 visible ASCII characters starting with a single / and without \ (NexusBuildError::InvalidLoginPath otherwise). The policy needs no TLS evidence; CSRF, the administrator role layer, tenant scope and the audit apply unchanged. local_development_or_application selects the debug loopback policy instead in debug builds.
  • The SaaS starter and the LMS example use local_development_or_application with a generated NexusAdmins authorizer (src/middlewares/nexus_access.rs) that admits the user of the epoch-checked session cookie whose users.is_admin column is true (default false, skipped by ORM writes, editable in Nexus; cargo rullst auth users have it too). The Blog, Portfolio and ERP examples (the former starters) keep local_development_or_basic_from_env(); when NEXUS_ADMIN_* are missing in a release build the Portfolio and ERP start without Nexus (the ERP also without its back office) and print one line naming the variables, and the Blog does not start; a weak, placeholder or unreadable credential stops startup in all three.
  • Basic Auth counts only presented Basic credentials that fail, per client bucket (IPv4 address or IPv6 /64 of the ConnectInfo peer); a credential-less 401 challenge is not a failure. A locked bucket receives 429 without credential evaluation, except for a browser holding the per-process rullst_nexus_known_client cookie issued on a previous success. Behind a shared reverse proxy, Core’s TrustedProxyLayer (Server::trusted_proxies) configured with only the proxy’s networks supplies the resolved client as ConnectInfo, isolating clients.
  • Basic Auth requires TLS evidence: the application-inserted NexusVerifiedTls marker, or a ClientAddr whose forwarded_proto() is HTTPS, which only the trusted-proxy layer can produce and only with trust_forwarded_proto(true). A request URI or forwarding header alone is never TLS evidence.
  • A model may explicitly declare one text tenant column. Nexus then obtains the scope only from a trusted Core TenantContext, injects it on create, includes it in every built-in read/mutation/batch predicate, and denies a missing context. Models without that metadata remain global administrator models. Authentication and tenant-membership resolution remain host contracts.
  • Updates write only submitted fields, and the edit form (data-nexus-mode="edit") submits only controls the administrator changed. NULL, unregistered enum values, offset or sub-millisecond date-times and undecodable values render without a widget default that could overwrite them. An emptied number/relation/date/date-time/enum/JSON value is NULL (omitted on create), never ''; the semantic validator rejects '' for those kinds. A FieldKind::Integer { min, max } value must be a whole number in range and is stored canonically; Number accepts any finite number. Date and time kinds are parsed per kind and bound as typed values: Date (YYYY-MM-DD, year 0001 to 9999) as a date, Time (HH:MM[:SS[.fraction]], v13) as a time, a local DateTime (T or space separator) as a date-time without a zone, and DateTimeUtc (v13) as a UTC instant, where a value without an offset is a wall time in the panel’s time zone and one with Z, ±HH:MM or ±HH is converted. The zone is UTC unless Nexus::with_time_zone selects a NexusTimeZone (v13, 13.0.0-alpha.3): a fixed offset, or with the tz feature (nexus-tz in the facade) an IANA zone whose rules come from jiff (system zoneinfo, bundled fallback). A wall time that a daylight-saving gap skips is a 422 naming the zone; one that a fold repeats is read as its earlier instant. A text column stores them as YYYY-MM-DD, HH:MM:SS[.fraction], YYYY-MM-DD HH:MM:SS[.fraction] (the text form of CURRENT_TIMESTAMP, so text comparison and ordering agree with application-written rows) and RFC 3339 respectively; a DateTime value with an offset is bound as text, as entered, which only a text column accepts. A malformed value is a 422 validation error. Time fields use a time input and DateTimeUtc fields a datetime-local input labelled with the zone’s name (UTC, UTC±HH:MM or the IANA name) that shows the stored instant as a wall time in that zone; the later occurrence of a repeated wall time is shown as its UTC value (Z) in a text input so saving the form unchanged keeps it, and a value either picker cannot hold exactly (finer than milliseconds) is shown in a text input.
  • Record keys follow the registered primary-key kind: a number, integer or relation key must be a canonical integer (+1, 01 and 1e3 name no record, so the audit key is the changed key); other kinds are compared as text. A model may declare a composite key (NexusModel::nexus_pk_columns, v13; at most 8 distinct registered columns, none of them the tenant column): a record is then addressed by its values in key order, each percent-encoded (urlencoding::encode) and joined by ,; only that canonical spelling with the right number of values names a record, each value typed by its column, and every key column is bound in list, edit, update, delete, batch and create predicates, read-only on edit and a sort tie-breaker. The UI addresses records under /table/{table}/record/{key} so keys such as new, search or batch never meet an action route. Other form values are bound as text; on PostgreSQL, numbers and integers are written through NUMERIC, canonical-integer relations through BIGINT, Booleans as untyped '0'/'1' literals and dates and times (including NULL) through DATE, TIME, TIMESTAMP or TIMESTAMPTZ casts, so native columns accept them, while other kinds need text columns. MySQL/MariaDB receive a UTC instant as its UTC wall time. Search matches the typed text literally and case-insensitively (ILIKE on PostgreSQL) in visible text, textarea, e-mail and URL fields; a model without one shows no search box and a search query lists no rows. The tenant predicate compares binary strings on MySQL/MariaDB.
  • Password fields are never rendered: list cells show a fixed mask (the column is not selected or sortable; neither is a hidden field, so a sort_by naming either orders by the key) and edit forms an empty input; an empty submission keeps the stored value. Non-empty values are written verbatim; Nexus neither hashes them nor runs ORM hooks, so credential-hash columns must be read-only in Nexus and changed by an application flow that hashes.
  • #[derive(Nexus)] follows ORM field semantics: #[orm(skip)]/#[sqlx(skip)] fields are not columns; #[orm(encrypted)], SecretString and #[orm(hidden)] fields become hidden, read-only Password fields that are never listed, searched, sorted, rendered or written (only #[nexus(kind = "password")] turns an #[orm(hidden)] field into a write-only field, and any other widget override on these fields fails compilation); #[orm(masked)] fields default to Password unless an explicit #[nexus(kind)] shows them. The record key is the struct-level primary_key (one column, or up to 8 distinct columns separated by commas), else every #[nexus(primary_key)] field in declaration order, else id; annotations must match a struct-level list. A single integer key is hidden and read-only as database-generated, while another key, and every column of a composite key, is entered on create and read-only on edit. chrono::NaiveDate, NaiveTime, NaiveDateTime and DateTime<Utc> fields become Date, Time, DateTime and DateTimeUtc. Rust integer fields (also under Option, and under an explicit kind = "number") become FieldKind::Integer bounded by their type (isize/usize as 64-bit, u64/usize capped at i64::MAX); f32/f64 stay Number.
  • The panel loads only same-origin assets under /nexus/assets/ (stylesheet, behaviour script and a vendored, unmodified htmx 2.0.4 with eval, script tags, injected indicator styles and its localStorage history cache disabled). Nexus markup contains no inline script/style blocks, event-handler, hx-on or style attributes, so the default production nonce CSP applies unchanged; the application-wide security.csp must not be relaxed for Nexus. AI assistant replies are sanitized to an explicit text-formatting allowlist without any element that loads a resource by itself (no img), and links get rel="noopener noreferrer".
  • with_required_audit requires the fixed rullst_nexus_audits schema and appends one minimized committed-mutation row in the same transaction. Audit unavailability rolls the data change back. This is not append-only, tamper-evident, denied-attempt, retention, backup, replication or external-SIEM evidence; those properties remain host responsibilities.

🛡️ 11. Architectural Guidelines for Backward Compatibility

  1. #[non_exhaustive] on Public Structs: All configuration structs and enums must use #[non_exhaustive] to ensure minor versions can add fields without breaking downstream code.
  2. Deprecation Policy (#[deprecated]): Public APIs will never be removed without at least one minor release cycle marked with #[deprecated].
  3. Ergonomic String Constructors: Public constructors accept impl Into<String> to support both &str literals and owned String parameters without boilerplate.
  4. Zero-Panic Invariant: Production paths must never call panic!(), unwrap(), or expect(); domain errors must return typed Result<T, AppError>.

11.1. Generated Project Context (v13)

generate:ai-context retains its existing helper signature but replaces raw source/configuration embedding with the rullst.project-context.v1 inventory. It writes a readable .llms.txt and machine-readable .rullst/context-map.json, and creates a project AGENTS.md only when no user instructions already exist. New project generation installs the same instructions and inventory. Existing AGENTS.md files are never rewritten by regeneration or automatic scaffold hooks.

The map contains bounded project/dependency/feature names, validated dependency version requirements, configuration key names, source paths and roles, file sizes and one deterministic input fingerprint. It contains no source bodies, configuration values, dependency URLs or absolute workstation paths. Only bounded Cargo.toml, optional Rullst.toml and .env.example, and regular .rs files beneath src are inspected. Secrets, databases, VCS metadata, build output, dependency directories, hidden descendants and symlinks are excluded or rejected explicitly. Directory depth, entries, individual files, aggregate reads and output size all have hard limits; errors must not echo configuration values. Generated instructions identify the map as an inventory, not evidence that a feature, deployment or test has passed. The dotenv inventory accepts single-line declarations and rejects multiline values before they can be mistaken for key names; it performs no interpolation. The selected root’s src is explicit: external workspace members are not scanned.

--check recomputes and compares the generated inventory without writing files; stale, missing or altered output fails. Normal regeneration uses preflighted atomic replacement/rollback, refuses links and unrecognized output, and retains the legacy generated .llms.txt migration boundary explicitly. The fingerprint detects changed Rust source and inventoried metadata, excluding configuration values and dependency URLs; it is not a signature or an authorization claim. The workspace directory is trusted against concurrent adversarial filesystem edits.

Local unit and real CLI/new-project contracts passed for the bounded inventory. Hosted and installed-archive source acceptance passed in PR #221, as recorded in the delivery plan; final release admission remains separate.

11.2. Schema-First API Profile (v13)

generate:api --schema <file> --output <directory> is an opt-in generator with one explicit OpenAPI 3.1 JSON source. It must reject unsupported keywords and shapes, external/cyclic references, duplicate keys, ambiguous names/routes and unbounded input before writing anything. The existing route-scanning generators remain discovery aids; their placeholder schemas do not enter this typed profile.

The initial profile admits closed named objects, bounded strings/arrays, booleans, JavaScript-safe integers, required nullable scalar/array fields and optional non-nullable fields. Optional nullable fields, arbitrary maps, polymorphism, floating-point/64-bit numeric wire values, formats and recursive schemas remain unsupported. References are local named object components. Parameters are explicit path strings and scalar query values; request and response bodies are named JSON objects with explicit status codes. Bearer authentication is described explicitly, while identity, ownership, CSRF and authorization remain host application responsibilities.

Generated Rust DTOs use Serde, and explicit operation codecs reuse Security’s bounded duplicate-key inspection and offline JSON Schema policies. They validate input before deserialization and output before serialization. Generated TypeScript uses strict types, runtime validation and typed status/body unions; its HTTP client bounds response bytes/time, encodes parameters, refuses redirects and never includes tokens or rejected payloads in errors. No route is silently mounted and no schema declaration constitutes authorization.

Generation preserves unrelated files, preflights recognized outputs and exposes a read-only freshness check. Hard budgets apply to source/output bytes, schemas, operations, properties, parameters and reference depth. The trusted project directory is not an adversarial concurrent filesystem boundary. Acceptance requires compiled generated Rust and strict TypeScript, plus a real HTTP consumer journey covering Unicode, missing versus null, bounds, typed errors and denied cross-owner access. Local generation, compiled Rust/strict TypeScript and HTTP consumer contracts passed, including read-only APIs, exact safe-integer bounds, nullable nested arrays, duplicate queries/JSON keys and cancellation. The initial implementation passed hosted and installed-archive source acceptance in PR #221; final release admission remains separate. See the supported profile for exact limits and application wiring.

11.3. Terminal AI Assistant (removed in 13.0.0-alpha.4)

cargo rullst ai, the terminal assistant of the earlier 13.0 prereleases (with connect, status, disconnect, upgrade, fix and review), is not part of 13.0. Its code remains in git history only and no package ships it. cargo rullst ai … is a hidden command that prints that it was removed, what replaces it and the migration guide link, and exits with status 1. Developers use their editor’s or terminal’s assistant with the project’s AGENTS.md and .llms.txt (cargo rullst generate:ai-context) and the published framework index (llms.txt). The rullst-ai crate is unaffected. The development error page’s “Ask your AI assistant” panel replaces cargo rullst ai fix <error-id>: a read-only prompt quoting only what the page shows (message, location, the first project stack frames, request method and path) and, when Server records the error, the path of its loopback-only JSON context (/_rullst/errors/{id}). The retired POST /_rullst/autofix points there.

🔄 12. Assisted Framework Upgrade Contract

cargo rullst upgrade is the canonical application-upgrade boundary. It is an assistant, not a claim that compilation proves production compatibility.

  • 🟢 [Implemented / Bounded] Planning: --dry-run enumerates only Cargo workspace manifests, preserves TOML comments/order, understands normal, inline-table, workspace, target-specific and renamed Rullst dependencies, and reports path/git dependencies that have no version instead of guessing. --dry-run --json emits the versioned rullst.upgrade-plan.v1 envelope.

  • 🟢 [Implemented / Bounded] Versioned Rules: source findings are selected from a versioned rule catalog using detected source majors and the exact target major. Every future major release must extend that catalog, migration documentation, negative tests and process-level fixtures for its supported upgrade paths. The v13 catalog rullst-upgrade-rules-v4 parses Rust sources with syn (comments, doc comments and strings never match an API rule; macro bodies are read as tokens), Cargo manifests and generated project files read without following symlinks. Each finding has a stable V13-* code, a kind (must-change for a compile-breaking or API-shape change, review for changed behaviour of an API, feature or configuration in use), file:line, a one-line message and the first-column title of its row in migration-v13.md; the assisted-upgrade tutorial classifies every row. Each rule records the release that introduced its change (13.0.0-alpha.1 for the 12.x to 13 changes, a later prerelease for rules added or reworded since). The plan takes the project’s Rullst version to be the oldest version a Rullst requirement admits or Cargo.lock holds and, for a project already on v13, applies only the rules introduced after it, reporting how many rules it skipped and how many findings they would have added; --all-rules applies every rule, and project preparations always do. rullst.upgrade-plan.v1 is extended additively (kind, introduced_in, migration_row, migration_url, finding_counts, unscanned_sources, migration_guide, rule_selection). Findings never block cargo rullst upgrade; project preparations with must-change findings are refused at verification, review and application.

  • 🟢 [Implemented / Bounded] Transaction: the default target is the exact installed cargo-rullst version; --to accepts only the same major train as that CLI. Before writes, the command snapshots workspace manifests, the root lockfile and Rust sources under target/rullst-upgrades. It applies only dependency edits and compiler-provided cargo fix changes, then requires cargo check --workspace --all-targets --locked to pass. Managed version requirements are exact =VERSION pins, so an explicitly selected release cannot silently resolve a later patch/minor release. cargo fix resolves the candidate lockfile; the final check must use that same resolution. A failed gate restores the snapshot by default; --keep-on-failure is explicit, and --restore can recover a persisted, path-validated snapshot after an interruption. Process fixtures upgrade v12 origins, reject retired pre-v12 origins, prove restoration across multiple workspace members, preserve a failed edit only when explicitly requested, restore that persisted review state, and reject symlinked Rust sources before starting the transaction. 12.1.0 recovery hardening: automatic and persisted restores validate the complete bounded index and every snapshot/target before staging all file replacements. Limits are 8 MiB/index, 100,000 entries, 64 MiB/file and 512 MiB/restore. Symlinks/reparse points, malformed or duplicate entries and non-regular files are rejected. Per-file replacement does not truncate a hardlinked destination. A later apply error may leave earlier files restored; it reports progress and retains the backup. Stop other writers first: this is neither an all-files atomic commit nor protection from hostile concurrent filesystem changes. Platform acceptance is scoped to the published release evidence.

  • 🟠 [Manual Application Boundary] the command never installs a CLI, changes secrets, executes database migrations, invents authorization or tenant policy, exposes Nexus/Studio, validates providers, or declares an application production-ready. Database restore/migration/rollback, the full test suite, authorization negatives and deployment smoke tests remain mandatory human-owned gates.

    Update notice (13.0.0-alpha.4): the CLI never downloads, verifies or replaces its own binary; a newer one is installed with cargo install cargo-rullst --version =X --locked. At most once a day, in an interactive terminal (every standard stream a terminal, not CI, RULLST_DISABLE_UPDATE_CHECK or CARGO_NET_OFFLINE), the CLI prints that exact command when crates.io has a newer non-yanked release of its major: a stable release, or for a prerelease CLI also a newer prerelease; never build metadata or another major. The notice reads only the private catalog cache and is shown on the home screen or on standard error after a command. A background thread refreshes that cache at most once a day with one fixed HTTPS request (no redirects, 256 KiB cap, one four-second deadline); a failure is silent. Daily stamps in the user cache (rullst-ui-v1/) record the last notice and the last request. The cache keeps the 12.1.0 Unix and Windows private-directory checks (caller-owned 0700 directory or protected DACL, single-link private files, no-follow bounded reads, non-blocking staged replacement) and reuses a catalog for at most 24 hours. It grants no installation, project or deployment authority.

    Native CLI artifact preparation: the candidate pipeline builds both CLI entry points on the four targets in .github/cli-artifact-targets.json and runs each executable’s version check on its native host. A bounded rullst.cli-artifacts.v1 inventory binds file names, sizes and SHA-256 digests to version, target, build runner, repository and source commit. Ordinary CI inventories have no release tag and cannot be promoted to release artifacts. These checks establish file integrity, not publisher authenticity. Trusted tag-workflow provenance, client verification, staged installation, recovery and application acceptance remain separate required boundaries; no build or discovery command installs these files automatically. The tag-only release pipeline now calls the same native builder after exact protected-main admission. A separate job with signing authority checks the downloaded checksums and attests executables, manifests and inventory files without checking out source or executing downloaded binaries. GitHub release assets include those files only after crate publication and attestation pass. The development release workflow also retains the original Sigstore bundle and its signed in-toto envelope as release assets and verifies every artifact against it with exact repository/workflow/tag/commit identity before release creation. Releases from 12.1.2 on, including the 13.0.0 prereleases, carry rullst-release.sigstore.json and rullst-release.intoto.jsonl; earlier release assets remain unchanged. See the OpenSSF evidence plan. Client-side verification must pin the publisher, tag workflow, source tag and commit and then compare the expected platform/version/file digest; metadata and matching checksums by themselves remain insufficient authority.

    Self-updater removed (13.0.0-alpha.4): update check, update verify, update stage and update install review|apply|recover (12.1.0 to 13.0.0-alpha.3) were removed with their download, provenance, installation receipt, rollback and pruning machinery. The native CLI executables and their rullst.cli-artifacts.v1 manifests remain attested GitHub release assets that users verify by hand (see SECURITY.md, “Verifying a release”).

    Guided update composition: update guided --to EXACT_VERSION is an opt-in interactive composition of the project commands (prepare, verify, review, apply). It reuses the selected version, directories and review digest. Show each complete review before a default-no approval; distinguish trusted project execution/network access and original-file application. No blanket approval or noninteractive implicit yes is accepted. Declining stops before the next operation, preserving any already completed step and its recovery evidence. The current CLI’s versioned migration rules remain authoritative, so --to must be in its major; since 13.0.0-alpha.4 the flow installs no CLI (--scope, --root, --allow-major and --prerelease were removed). Existing structured commands remain the automation interface. Report elapsed time per executed stage, excluding user input; do not promise instant upgrades.

    Isolated project preparation (working source): the opt-in update project prepare command snapshots tracked and non-ignored untracked files from the selected Git working directory into private caller-owned storage, preserving uncommitted source contents and deletions. Links, special files, unsupported paths and oversized inputs fail before migration. The copy is bounded to 100,000 entries, 64 MiB per file and 512 MiB total. Cargo metadata and dependency planning run only inside that copy, with Cargo network access and Rustup auto-installation disabled. The existing exact-version manifest editor and the versioned catalog records its review report; preparation executes no build scripts, procedural macros or application tests and grants no apply authority. Ignored files, including typical secret files and build outputs, are not copied, except the root Cargo.lock (legacy generators ignored that reproducibility input). Version requirements and existing locked Rullst packages must not imply a downgrade; unsupported or ambiguous requirements require manual review. This is a source snapshot, not a filesystem sandbox: later verification must explicitly authorize trusted project execution. Bounded reviewed application/recovery have separate explicit consent and acceptance gates.

    Candidate verification (12.1.0): update project verify reloads the private preparation, validates its baseline/current source/candidate against bounded records and recomputed migration plans, rejects stale inputs, and takes an exclusive operation lock. --dry-run shows the selected commands without running builds/tests. Execution requires --allow-project-code and uses another fresh private copy. Cargo resolves the lockfile; every locked managed Rullst package must match the exact target before checking all workspace targets and running workspace tests with --locked and the selected feature policy. Default features are the default policy, with explicit all/custom/no-default selection. Cargo is offline unless separately authorized; an offline environment cannot be overridden. Rustup auto-installation remains disabled. Build outputs live outside the verified source tree. Each tool has a bounded deadline (900 seconds by default, at most 3,600) and at most 8 MiB per output stream. Execution reuses the supervised child/process-group cleanup and responds to cancellation. This is best-effort process-tree cleanup, not containment of hostile descendants. Private logs include Cargo/rustc version probes, commands and their output digests. Configured compiler wrappers or alternate toolchains still require operator review; probes are not toolchain attestations. Original/prepared/baseline changes, unresolved must-change migration findings (review findings are only reported), failed commands and unexpected source writes reject acceptance. Only the candidate root lockfile may change during verification. rullst.project-verification.v1 records successful commands, feature policy and final file digests, grants no application/deployment authority, and is not a reusable apply token. Native acceptance and complete application/recovery remain release gates. Tests execute trusted project code with the caller’s environment: the copy is not a sandbox, and external effects cannot be reversed through source-file recovery.

    Explicit candidate review (working source): update project review revalidates the private preparation, verified file inventory and bounded successful command logs under both operation locks, then shows the complete manifest/lockfile diff and a SHA-256 digest binding that review. Git external diff/text-conversion helpers and paging are disabled; the diff is bounded to 8 MiB and no build/test or original-file edit occurs. The review digest grants no application authority. Native acceptance remains required.

    Reviewed application/recovery (12.1.0): update project apply --verified PATH --approved-review SHA256 requires the exact review digest and fresh source validation under both preparation locks and a canonical-source lock in the configured private cache. Only the reviewed workspace manifests and root lockfile are eligible. Stage all replacements before changing originals, preserve original permissions and replace directory entries without truncating hardlinks. Unix mode/owner/group and Windows owner/group/DACL/integrity label enter the review digest, with a 1 MiB aggregate serialized access-policy limit. Unix extended ACLs/xattrs and special mode bits require manual updates, except the SELinux security.selinux label that the staged same-directory replacement receives anyway; Windows read-only/special attributes, alternate streams, resource/central-access policies and access policies that cannot be recreated exactly fail before source writes. Darwin extended ACLs are inspected through a narrowly scoped OS FFI module, since they are separate from xattr names. It owns and frees the returned ACL and never changes it; a native ACL regression and the exact unsafe-source allowlist govern this exception. The API follows Apple’s ACL entry contract. Staging installs the access policy before writing candidate contents. Keep a durable bounded intent record and before/after digests for interruption recovery. Recovery must refuse divergent user edits and may restore only files from that intent; absent original root lockfiles may be removed. Per-file replacement is not a single atomic workspace commit. update project recover with the same approval accepts only the recorded before/after states and supports repeated recovery. Locks coordinate this CLI under the same cache configuration, not editors, other caches or filesystem aliases. Stop other writers first; hostile concurrent renames, external test effects and databases remain outside file recovery. Forced process termination during staging can leave disposable sibling temp files; the private before/verified trees and intent must be retained. Timestamp, Windows audit-policy preservation and power-loss fault acceptance remain outside this implementation. Published acceptance covers only the declared platform and fault scenarios. Local process tests cover a killed per-file commit with a persisted intent and subsequent engine recovery, plus real CLI staging terminated by Linux’s file-size limit without changing originals. They do not establish power-loss durability.


📱 13. Installable Applications: Progressive Web App Contract

Since 13.0.0-alpha.3 an application is made installable as a Progressive Web App. The canonical product stays the Rullst web application; the browser, or a store package of the same site, only installs it.

  • 🟢 [Implemented / Bounded] cargo rullst make:pwa: for an HTML starter it writes static/manifest.webmanifest (id and scope /, display standalone, name, short_name, start_url, theme_color, background_color, lang, PNG icons 192 and 512 plus a maskable 512), four placeholder icons pre-rendered from the Rullst logo (no image library), static/pwa.js, src/pwa.rs, the worker src/pwa/sw.js and PWA.md. Values come from validated flags or the Cargo package name. It mounts crate::pwa::router() after the crate root’s single .merge_axum(rullst::health::health_router()) (merges inside a return expression are fallback routers and are ignored), adds the head tags to every recognized layout in src/ (an html! document, a document in a string literal, Core’s render_page_with_lang, rewritten to render_page_with_head) and declares mod pwa in src/lib.rs, else src/main.rs. The complete plan is validated before the first write: identical files and wired places are skipped, so a repeated run changes nothing, and a generated file with other content (edited, or rendered from other flags) refuses the run. Projects without an HTML layout (the JSON API starter), without the health merge or with a source file that does not parse are refused.

  • 🟢 [Implemented / Bounded] Routes and headers: /sw.js answers text/javascript; charset=utf-8 with Cache-Control: no-cache from the site root, so the worker’s scope is /; its cache is named after the package version. /offline is an html! page with no-cache. Both answer GET only and are mounted outside the CSRF layer. Static files keep Core’s policy (no-cache, or a year-long immutable for content-hashed names) and the manifest Core’s application/manifest+json type.

  • 🟢 [Implemented / Bounded] CSP compatibility: the registration script is a same-origin file allowed by script-src 'self', it styles the update prompt only through the DOM, the worker is allowed by worker-src 'self', and the manifest by the default-src 'self' fallback. No inline script or style is added. A policy with 'strict-dynamic' needs the request nonce on the script.

  • 🟢 [Implemented / Bounded] Worker storage rule: it precaches /offline (required) and the PWA’s own and the starter’s static files (when present); navigations and unversioned static files are network-first with a stored-copy or offline fallback; content-hashed static files are cache-first; other requests pass through. It stores only same-origin 200 basic responses to GET requests whose Cache-Control has neither no-store nor private and whose Vary is not *; since Core answers no-store by default, authenticated pages are not kept. A waiting worker takes over only after the user accepts the page’s reload prompt (SKIP_WAITING); the old version’s cache is then deleted. node cargo-rullst/src/generators/pwa/assets/pwa_tests.cjs executes both scripts against in-memory browser APIs in CI, and a generated Blank application is compiled and checked for these headers.

  • 🟢 [Implemented / Bounded] Google Play (Trusted Web Activity): make:pwa --android --host <host> --package-id <id> also writes the Bubblewrap android/twa-manifest.json from the same validated values (packageId, host, name, launcherName, startUrl, iconUrl, maskableIconUrl, webManifestUrl, fullScopeUrl, theme, navigation and background colours, display, appVersion/appVersionCode 1, enableNotifications: false and a placeholder signingKey outside the repository) and PWA-ANDROID.md. The host is a lowercase DNS name with an alphabetic top label; the package ID has lowercase segments and may not be com.example.*. rullst::pwa::AssetLinks serves /.well-known/assetlinks.json (application/json, public, max-age=3600) with one delegate_permission/common.handle_all_urls statement per AndroidApp; package names are Android application IDs and each fingerprint is 32 colon-separated hexadecimal bytes (normalized to upper case), validated at construction with the typed AssetLinksError. AssetLinks::from_project reads RULLST_ANDROID_ASSET_LINKS from the process environment, then .env, then [[android.asset_links]] in Rullst.toml; parse errors report only a line and column. With no app the router has no route. The generated src/pwa.rs mounts it and reports an invalid configuration at startup instead of serving it. Bubblewrap, the signing key, Play App Signing and store review remain application work.

  • 🟢 [Implemented / Bounded] render_page_with_head: Core’s render_page_with_lang with extra Markup at the end of <head>; an htmx request still receives only the content.

  • 🟠 [Application Boundary] icons, HTTPS, offline data and conflict handling belong to the application. Browser-side offline data follows the server-authoritative pattern of the PWA guide (an IndexedDB outbox replayed with idempotency keys); no sync engine is generated.

  • 🟠 [Documented / External Tools] Other stores: the PWA guide documents packaging the same site as MSIX for the Microsoft Store with PWABuilder and a Partner Center reservation, as Microsoft documents it, and installation on iPhone and iPad from Safari. No Windows or App Store package is generated or verified by the framework; App Store Review Guideline 4.2 usually rejects an app that only wraps a website.

  • 🟢 [Implemented / Bounded] Shared Wire Contract: rullst::client_contract provides the strict rullst.client v1 JSON marker, positive version negotiation, private typed request/success/failure envelopes, bounded log-safe correlation and idempotency tokens, server time, message-free dotted failure codes and a codec capped at 2 MiB. Unknown outer fields, unsupported versions and oversized bodies fail closed. The same code compiles natively and for wasm32-unknown-unknown. The envelope carries no role, tenant or authorization claim; the server must derive those from its authenticated context and persist idempotency atomically.

  • ⚪ [Removed in 13.0.0-alpha.3] Omni: cargo rullst make:omni, cargo rullst omni (including omni android --release), the Tauri shell templates, their three release workflows and the native-only offline-sync feature (rullst::offline_sync) were removed. The shells exposed no native API to the site, so an installed PWA gives the same result. cargo rullst upgrade reports an Omni shell or script (V13-OMNI-REMOVED) and a manifest that enables offline-sync (V13-OFFLINE-SYNC-REMOVED); see the migration guide.