Skip to main content

yeti_types/resource/
context.rs

1//! `Context` — the request pipeline object, restructured into 5 typed blocks
2//! per ADR-011 (Stage 2/3/6).
3//!
4//! The five blocks:
5//!
6//! - **Required:** `request`, `lineage`
7//! - **Optional middleware-derived:** `routing`, `identity`
8//! - **Host-only** (not crossing the WIT ABI): `backend_manager`, `pubsub_lookup`,
9//!   `requested_format`, `json_body`, `root_directory`
10//!
11//! `Response` is **not** a field of Context — it's instantiated alongside Context
12//! by the request pipeline and threaded through dispatch separately. In WIT it's
13//! an owned resource handle; here it's a value-typed [`Response`].
14
15use ecow::EcoString;
16use std::collections::HashMap;
17use std::sync::Arc;
18// `OnceLock` (not `OnceCell`) so `Context: Sync` holds — customer
19// resource bodies routinely pass `&Context` across `.await` points,
20// which requires `Sync`. wasm32-wasip2 is single-threaded so the
21// internal lock is uncontended; cost is negligible.
22#[cfg(target_arch = "wasm32")]
23use std::sync::OnceLock;
24
25use bytes::Bytes;
26
27use super::permission::TablePermission;
28use crate::auth::{Access, AuthIdentity};
29use crate::error::{Result, YetiError};
30
31// ============================================================================
32// Lazy WIT backing (wasm32 only)
33//
34// On the wasm guest side, the host hands the customer's dispatch
35// function an owned `Context` handle (mirrors wasi:http). The
36// guest-side bindgen-generated handle type doesn't live in
37// yeti-types — but yeti-types' `Context` needs to defer field
38// materialization until the customer reads a field. To square that,
39// we define an abstract `WitContextHandle` trait here and let the
40// `lib_gen_wasm`-emitted glue impl it on the bindgen-generated type.
41// Customer-facing `Context` accessors then call through `Box<dyn
42// WitContextHandle>` with one-time caching via `OnceCell`.
43// ============================================================================
44
45/// Wasm-guest-only: abstracts the WIT `borrow<context>` handle.
46/// `lib_gen_wasm`-generated guest code implements this on the
47/// bindgen-generated `Context` resource type so `yeti_types::resource::
48/// Context` can hold a trait-object handle and lazy-fetch fields via
49/// canonical-ABI accessor crossings.
50///
51/// Each `fetch_*` method is called at most once per Context (cached
52/// in [`LazyBacking`] `OnceCell`s).
53#[cfg(target_arch = "wasm32")]
54pub trait WitContextHandle: Send + Sync {
55    fn fetch_request(&self) -> Request;
56    fn fetch_routing(&self) -> Option<Routing>;
57    fn fetch_lineage(&self) -> Lineage;
58    fn fetch_identity(&self) -> Option<Identity>;
59}
60
61/// Wasm-guest-only: the per-Context lazy backing. Holds the WIT
62/// handle behind `Box<dyn>` and per-field `OnceCell`s that cache the
63/// first fetch. Only `request` and `routing` are lazy — `lineage`
64/// and `identity` are eager-fetched at construction because they're
65/// tiny and almost universally read (logging/authz).
66///
67/// Construction is via [`Context::from_wit_handle`] (called by
68/// `lib_gen_wasm`-generated `invoke_service`). Accessor methods on
69/// `Context` check `lazy` first; if present and the relevant
70/// `OnceCell` is uninitialized, they call through `handle` and cache.
71/// If `lazy` is `None`, the eager fields on `Context` are used
72/// (e.g., for `placeholder()` contexts used in queue-fn invocations).
73#[cfg(target_arch = "wasm32")]
74pub(crate) struct LazyBacking {
75    pub(crate) handle: Box<dyn WitContextHandle>,
76    pub(crate) request: OnceLock<Request>,
77    pub(crate) routing: OnceLock<Option<Routing>>,
78}
79
80#[cfg(target_arch = "wasm32")]
81impl std::fmt::Debug for LazyBacking {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        f.debug_struct("LazyBacking")
84            .field("handle", &"<dyn WitContextHandle>")
85            .finish_non_exhaustive()
86    }
87}
88
89// ============================================================================
90// Sub-records — the five typed blocks
91// ============================================================================
92
93/// Inbound request block: verb, protocol, path, body, headers, and parsed query.
94///
95/// `path` and `headers` are stored as `Arc` so that `Context::clone()` —
96/// which fires in WebSocket dispatch — is a refcount bump rather than a deep
97/// copy. The initial allocation per request is identical; the gain is on the
98/// clone path (WS upgrade, auth middleware that clones context, etc.).
99#[derive(Clone, Debug)]
100pub struct Request {
101    /// HTTP method (or the protocol-equivalent verb for non-HTTP transports).
102    pub verb: http::Method,
103    /// Wire protocol the request arrived on (REST / `WebSocket` / MQTT / …).
104    pub protocol: crate::schema::Protocol,
105    /// Request path, app-prefix included (e.g. `/yeti-auth/Users/alice`).
106    /// `EcoString`: inline (zero heap) for paths ≤ 15 bytes, Arc-backed clone
107    /// for longer paths. Either way `Context::clone()` is O(1).
108    pub path: EcoString,
109    /// Raw request body bytes (refcounted; cheap to clone).
110    pub body: Bytes,
111    /// Inbound request headers.
112    /// `Arc` so `Context::clone()` is a refcount bump, not a full `HeaderMap` copy.
113    pub headers: Arc<http::HeaderMap>,
114    /// Parsed query string, plus synthetic keys routing injects downstream.
115    pub query: HashMap<String, String>,
116}
117
118impl Default for Request {
119    fn default() -> Self {
120        Self {
121            verb: http::Method::GET,
122            protocol: crate::schema::Protocol::default(),
123            path: EcoString::new(),
124            body: Bytes::new(),
125            headers: Arc::new(http::HeaderMap::new()),
126            query: HashMap::new(),
127        }
128    }
129}
130
131/// Outbound response block: status, headers, and body bytes.
132#[derive(Clone, Debug, Default)]
133pub struct Response {
134    /// HTTP status code.
135    pub status: u16,
136    /// Response headers.
137    pub headers: http::HeaderMap,
138    /// Response body bytes (refcounted; cheap to clone).
139    pub body: Bytes,
140}
141
142/// Request-lineage block: identifiers and clock stamp for tracing/correlation.
143#[derive(Clone, Debug, Default)]
144pub struct Lineage {
145    /// Request id (`UUIDv7`), stamped by the inbound router.
146    pub request_id: String,
147    /// Parent request id when triggered by another request; `None` at the root.
148    pub parent: Option<String>,
149    /// Cross-request correlation id (e.g. inbound `X-Correlation-Id`).
150    pub correlation: Option<String>,
151    /// Deployment hash of the producing yeti-fabric node (`"local"` in dev).
152    pub deployment: String,
153    /// Hybrid Logical Clock stamp at request receipt.
154    pub hlc: Hlc,
155}
156
157/// Hybrid Logical Clock stamp — physical time plus a logical tiebreaker and node id.
158#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
159pub struct Hlc {
160    /// Physical time component (milliseconds since the Unix epoch).
161    pub physical: u64,
162    /// Logical counter, incremented for events sharing a `physical` tick.
163    pub logical: u32,
164    /// Originating node id, breaking ties across nodes.
165    pub node_id: u32,
166}
167
168/// Routing block: app/resource/database identifiers settled at request-routing time.
169#[derive(Clone, Debug)]
170pub struct Routing {
171    /// Application id — first URL segment, e.g. `"yeti-auth"`.
172    pub app: Arc<str>,
173    /// Resource id — second URL segment, e.g. `"Users"`. For table-backed
174    /// resources this is the table name.
175    pub resource: Arc<str>,
176    /// Database name — settled at request-routing time from the
177    /// `TableDefinition.database` (or app default `"data"` when the
178    /// table doesn't specify). **Distinct from `app`**: the
179    /// `@database` schema directive can point a table at a database
180    /// shared across apps. Permission checks read this field.
181    pub database: Arc<str>,
182    /// `None` = collection-level operation; `Some(k)` = single-record operation.
183    pub key: Option<String>,
184}
185
186/// Resolved identity block — the post-auth view that crosses the WIT ABI.
187#[derive(Clone, Debug)]
188pub struct Identity {
189    /// Authenticated principal (username / email / service id); `None` if anonymous.
190    pub principal: Option<String>,
191    /// Resolved role names.
192    pub roles: Vec<String>,
193    /// Resolved permission bundle (`super_user` bit + per-resource op masks).
194    pub permissions: Permissions,
195    /// Authentication method that produced this identity.
196    pub auth_method: AuthMethod,
197}
198
199/// Resolved permission bundle: a `super_user` override plus per-resource op masks.
200#[derive(Clone, Debug, Default)]
201pub struct Permissions {
202    /// When set, grants all operations on all resources (bypasses `per_resource`).
203    pub super_user: bool,
204    /// Per-resource operation grants.
205    pub per_resource: Vec<ResourcePerm>,
206}
207
208/// One resource's operation grant within a [`Permissions`] bundle.
209#[derive(Clone, Debug)]
210pub struct ResourcePerm {
211    /// Resource (table) name the mask applies to.
212    pub resource: String,
213    /// Allowed operations on `resource`.
214    pub ops: OpsMask,
215}
216
217/// Per-resource operation permission bitfield. Plain u8 wrapper to avoid
218/// a workspace-level bitflags dep at the foundation layer.
219#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
220pub struct OpsMask(pub u8);
221
222impl OpsMask {
223    /// Create / insert a record.
224    pub const CREATE: Self = Self(1 << 0);
225    /// Read / query a record.
226    pub const READ: Self = Self(1 << 1);
227    /// Update an existing record.
228    pub const UPDATE: Self = Self(1 << 2);
229    /// Delete a record.
230    pub const DELETE: Self = Self(1 << 3);
231    /// Subscribe to a record/collection change stream.
232    pub const SUBSCRIBE: Self = Self(1 << 4);
233    /// Publish to a topic/collection.
234    pub const PUBLISH: Self = Self(1 << 5);
235    /// Open a connection (`WebSocket` / MQTT).
236    pub const CONNECT: Self = Self(1 << 6);
237
238    /// An empty mask (no operations granted).
239    #[must_use]
240    pub const fn empty() -> Self {
241        Self(0)
242    }
243    /// `true` if `self` grants every bit set in `other`.
244    #[must_use]
245    pub const fn contains(self, other: Self) -> bool {
246        self.0 & other.0 == other.0
247    }
248    /// Set the bits in `other`.
249    pub const fn insert(&mut self, other: Self) {
250        self.0 |= other.0;
251    }
252    /// Clear the bits in `other`.
253    pub const fn remove(&mut self, other: Self) {
254        self.0 &= !other.0;
255    }
256}
257
258impl std::ops::BitOr for OpsMask {
259    type Output = Self;
260    fn bitor(self, rhs: Self) -> Self {
261        Self(self.0 | rhs.0)
262    }
263}
264
265/// The authentication scheme that produced a request's identity.
266#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
267pub enum AuthMethod {
268    /// Unauthenticated (anonymous / public endpoint).
269    #[default]
270    None,
271    /// HTTP Basic credentials.
272    Basic,
273    /// JWT bearer token.
274    Jwt,
275    /// OAuth session.
276    OAuth,
277    /// API key.
278    ApiKey,
279    /// Internal service token.
280    ServiceToken,
281}
282
283// ============================================================================
284// AuthBlock — aggregates the three authentication/authorization fields.
285// ============================================================================
286
287/// Authentication and authorization state aggregated into one nested block.
288///
289/// Holds the three host-side auth-related fields that flow through the
290/// request pipeline. Each sub-field carries distinct semantic content:
291///
292/// - [`identity_input`](Self::identity_input) — INPUT from a credential
293///   provider (Basic/JWT/OAuth/mTLS). Set by the auth pipeline's
294///   `AuthProvider::authenticate()`. Represents WHO the wire claims to
295///   be, before role resolution. Renamed from the flat `auth_identity`
296///   field to disambiguate from [`Context::identity`] (the WIT-mirrored
297///   *output* shape exposed to wasm apps).
298/// - [`access`](Self::access) — OUTPUT after the auth middleware
299///   resolves `identity_input` into a `User` + `Role`. Closed enum
300///   ([`Access`]). All RBAC helpers (`can_read_table`,
301///   `can_update_table`, attribute filtering) dispatch through this.
302/// - [`permission`](Self::permission) — Per-request cached
303///   [`TablePermission`] computed once by the auth layer for the
304///   currently-routed table and consumed on the dispatch hot path.
305///   `FullAccess` (super-user / dev mode / wildcard) or
306///   `AttributeRestricted` (narrow read/write lists). `Public`
307///   short-circuits all RBAC for `@export(public:[...])` tables.
308///
309/// **Relationship to `Context.identity`**: That top-level field is the
310/// pure-WIT *resolved* view (principal + roles + permissions bitmask
311/// shape) crossing the WIT boundary to wasm apps. `AuthBlock` is the
312/// host-side intermediate state used during dispatch. They overlap in
313/// content but differ in lifetime and WIT surface.
314#[derive(Clone, Debug)]
315pub struct AuthBlock {
316    /// Wire-level authentication claim, set by the auth pipeline before
317    /// role resolution. `None` for unauthenticated requests.
318    pub identity_input: Option<AuthIdentity>,
319    /// Resolved user + role after the auth middleware. `Access::None`
320    /// when unauthenticated.
321    pub access: Access,
322    /// Pre-computed table permission for the routed table, cached on
323    /// the request hot path.
324    pub permission: TablePermission,
325}
326
327impl Default for AuthBlock {
328    fn default() -> Self {
329        Self {
330            identity_input: None,
331            access: Access::None,
332            permission: TablePermission::FullAccess,
333        }
334    }
335}
336
337/// Result of dispatching a request: either a final response or a delegation.
338#[derive(Debug)]
339pub enum Outcome {
340    /// Terminal response — send it to the client.
341    Respond(Response),
342    /// Re-dispatch to another app/resource per the [`DelegateSpec`].
343    Delegate(DelegateSpec),
344}
345
346/// Target of an [`Outcome::Delegate`] — the app/resource/key to re-dispatch to.
347#[derive(Clone, Debug)]
348pub struct DelegateSpec {
349    /// Target application id.
350    pub app: String,
351    /// Target resource (table) name.
352    pub resource: String,
353    /// Target record key; `None` for a collection-level operation.
354    pub key: Option<String>,
355}
356
357// ============================================================================
358// Context — five-block, with host-only side-channel fields
359// ============================================================================
360
361/// Request context. Five typed blocks per ADR-011 plus host-only side fields.
362///
363/// The shape after Wave-3 collapse (2026-05-25):
364///
365/// - **WIT-mirrored blocks** crossing the ABI: `request`, `lineage`,
366///   `routing` (optional), `identity` (optional — the resolved view).
367/// - **Host-side auth state** consumed by the dispatch pipeline:
368///   [`auth: AuthBlock`](AuthBlock) holding `identity_input` (wire-level
369///   credential), `access` (resolved `User`+`Role`), and `permission`
370///   (per-request `TablePermission` cache).
371/// - **Host-only side-channel** for backend manager, pubsub lookup,
372///   parsed JSON body, requested content-type, and rootDirectory.
373///
374/// `auth.identity_input` is renamed from the old flat `auth_identity`
375/// field to avoid colliding with the WIT-side `identity` block above
376/// (which is the post-auth *resolved* shape that crosses the ABI).
377///
378/// `Clone` is implemented manually so the wasm-only `LazyBacking`
379/// field can be reset on clone (the underlying `Box<dyn
380/// WitContextHandle>` isn't itself `Clone`). Cloned wasm Contexts
381/// inherit eager fields only — host code paths use Clone freely.
382pub struct Context {
383    // ── 5-block shape (mirrors WIT yeti:types/core record context) ──
384    // ── WIT-backed fields ───────────────────────────────────────────────
385    //
386    // These four cross the WIT canonical-ABI boundary on wasm32
387    // (lazy-fetched via WitContextHandle for request/routing, eager
388    // for lineage/identity). Access through the accessor methods on
389    // this impl — direct field access is `pub(crate)` only so the
390    // host crate's bridge can construct/mutate them. Migration debt
391    // tracker: all external reads route through accessors per the
392    // wasi:http resource pattern.
393    pub(crate) request: Request,
394    pub(crate) lineage: Lineage,
395    pub(crate) routing: Option<Routing>,
396    pub(crate) identity: Option<Identity>,
397
398    /// Host-side authentication/authorization state consumed by the dispatch pipeline.
399    pub auth: AuthBlock,
400
401    // ── Host-only side-channel — not crossing the WIT ABI ──
402    /// Negotiated response content-type (from the `Accept` header).
403    pub requested_format: crate::content_type::ContentType,
404    /// Request body parsed as JSON — lazily on first access and cached.
405    /// Read via [`Context::json_body`] / `require_json_body`; requests that
406    /// never touch it (e.g. public/super-user writes) never pay the parse.
407    /// Boxed so the cell is one pointer wide: `OnceLock<Option<Box<_>>>` is
408    /// smaller than the bare `Option<Value>` it replaced, keeping the read
409    /// path (which never parses) from paying for a fatter `Context`.
410    json_body: std::sync::OnceLock<Option<Box<serde_json::Value>>>,
411    /// Backend manager for table access; `None` on contexts without storage.
412    pub backend_manager: Option<Arc<crate::backend::BackendManager>>,
413    /// Deployment root directory (rootDirectory), used to resolve on-disk paths.
414    pub root_directory: Arc<str>,
415    /// Lookup for a table's `PubSub` manager; `None` when pub/sub is unavailable.
416    pub pubsub_lookup: Option<crate::plugins::PubSubLookupFn>,
417
418    // ── Wasm-guest lazy backing — only present when constructed via
419    //    [`Context::from_wit_handle`]. Accessor methods (`path`,
420    //    `headers`, `routing`, ...) check this first and lazy-fetch
421    //    if set. `None` means the Context uses the eager fields above
422    //    (host build, or wasm-side placeholder).
423    #[cfg(target_arch = "wasm32")]
424    pub(crate) lazy: Option<LazyBacking>,
425}
426
427impl std::fmt::Debug for Context {
428    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
429        f.debug_struct("Context")
430            .field("request", &self.request)
431            .field("lineage", &self.lineage)
432            .field("routing", &self.routing)
433            .field("identity", &self.identity)
434            .field("auth", &self.auth)
435            .field("requested_format", &self.requested_format)
436            .field("json_body", &self.json_body.get())
437            .field("root_directory", &self.root_directory)
438            .finish_non_exhaustive()
439    }
440}
441
442impl Clone for Context {
443    fn clone(&self) -> Self {
444        Self {
445            request: self.request.clone(),
446            lineage: self.lineage.clone(),
447            routing: self.routing.clone(),
448            identity: self.identity.clone(),
449            auth: self.auth.clone(),
450            requested_format: self.requested_format,
451            json_body: self.json_body.clone(),
452            backend_manager: self.backend_manager.clone(),
453            root_directory: Arc::clone(&self.root_directory),
454            pubsub_lookup: self.pubsub_lookup.clone(),
455            // Wasm-only: lazy backing is NOT cloned — the
456            // `Box<dyn WitContextHandle>` has unique ownership of
457            // the WIT resource handle, and a clone would result in
458            // double-drop of the canonical-ABI resource. Cloned
459            // wasm Contexts behave as if constructed from the eager
460            // fields only.
461            #[cfg(target_arch = "wasm32")]
462            lazy: None,
463        }
464    }
465}
466
467impl Context {
468    /// Create a Context from a raw inbound request. Subsequent layers fill in
469    /// `routing` (router), `identity` (auth pipeline), and other side fields.
470    pub fn from_request(
471        method: http::Method,
472        path: impl Into<EcoString>,
473        body: Bytes,
474        headers: http::HeaderMap,
475    ) -> Self {
476        Self {
477            // Nested 5-block shape
478            request: Request {
479                verb: method,
480                protocol: crate::schema::Protocol::default(),
481                path: path.into(),
482                body,
483                headers: Arc::new(headers),
484                query: HashMap::new(),
485            },
486            lineage: Lineage::default(),
487            routing: None,
488            identity: None,
489            auth: AuthBlock::default(),
490            // Host-only side fields
491            requested_format: crate::content_type::ContentType::Json,
492            json_body: std::sync::OnceLock::new(),
493            backend_manager: None,
494            root_directory: Arc::from(""),
495            pubsub_lookup: None,
496            #[cfg(target_arch = "wasm32")]
497            lazy: None,
498        }
499    }
500
501    /// Wasm-guest constructor: wraps an owned WIT handle for lazy
502    /// field fetching. Eager fields default-empty; accessor methods
503    /// fetch + cache via the handle on first call.
504    ///
505    /// Emitted by `lib_gen_wasm`'s `invoke_service` so the customer's
506    /// resource handler receives a Context that only crosses the WIT
507    /// boundary for fields it actually reads.
508    #[cfg(target_arch = "wasm32")]
509    #[must_use]
510    pub fn from_wit_handle(handle: Box<dyn WitContextHandle>) -> Self {
511        // Lineage + identity are small and almost always touched
512        // (logging, authz) — eager-fetch them at construction.
513        // Request + routing carry headers/body/query and are the
514        // real lazy win, so they go behind `OnceCell` and only
515        // cross the WIT ABI when the customer actually reads them.
516        let lineage = handle.fetch_lineage();
517        let identity = handle.fetch_identity();
518        Self {
519            request: Request {
520                verb: http::Method::GET,
521                protocol: crate::schema::Protocol::default(),
522                path: EcoString::new(),
523                body: Bytes::new(),
524                headers: Arc::new(http::HeaderMap::new()),
525                query: HashMap::new(),
526            },
527            lineage,
528            routing: None,
529            identity,
530            auth: AuthBlock::default(),
531            requested_format: crate::content_type::ContentType::Json,
532            json_body: std::sync::OnceLock::new(),
533            backend_manager: None,
534            root_directory: Arc::from(""),
535            pubsub_lookup: None,
536            lazy: Some(LazyBacking {
537                handle,
538                request: OnceLock::new(),
539                routing: OnceLock::new(),
540            }),
541        }
542    }
543
544    /// Vacant Context used as a swap target for Tower `Service<Context>` chains.
545    #[must_use]
546    pub fn placeholder() -> Self {
547        Self::from_request(
548            http::Method::GET,
549            String::new(),
550            Bytes::new(),
551            http::HeaderMap::new(),
552        )
553    }
554
555    /// Context for `on_ready` / queue-worker / scheduled dispatch.
556    #[must_use]
557    pub fn for_service(
558        app_id: Arc<str>,
559        root_directory: Arc<str>,
560        backend_manager: Option<Arc<crate::backend::BackendManager>>,
561        pubsub_lookup: Option<crate::plugins::PubSubLookupFn>,
562    ) -> Self {
563        let mut ctx = Self::placeholder();
564        ctx.set_routing(Some(Routing {
565            app: app_id,
566            resource: Arc::from(""),
567            database: Arc::from(""),
568            key: None,
569        }));
570        ctx.root_directory = root_directory;
571        ctx.backend_manager = backend_manager;
572        ctx.pubsub_lookup = pubsub_lookup;
573        ctx
574    }
575
576    // ── Lazy field-fetch helpers (wasm guest only) ──
577    //
578    // On wasm32 with a `lazy` backing set (via [`Context::from_wit_handle`]),
579    // these resolve fields by calling the bindgen-generated WIT
580    // accessors through `Box<dyn WitContextHandle>` and caching in
581    // per-field `OnceCell`s. Without a backing (host build, or
582    // wasm-side `placeholder()`), they return the eager fields.
583
584    #[cfg(target_arch = "wasm32")]
585    #[inline]
586    fn request_ref(&self) -> &Request {
587        if let Some(lazy) = &self.lazy {
588            lazy.request.get_or_init(|| lazy.handle.fetch_request())
589        } else {
590            &self.request
591        }
592    }
593    #[cfg(not(target_arch = "wasm32"))]
594    #[inline]
595    const fn request_ref(&self) -> &Request {
596        &self.request
597    }
598
599    #[cfg(target_arch = "wasm32")]
600    #[inline]
601    fn routing_ref(&self) -> Option<&Routing> {
602        if let Some(lazy) = &self.lazy {
603            lazy.routing
604                .get_or_init(|| lazy.handle.fetch_routing())
605                .as_ref()
606        } else {
607            self.routing.as_ref()
608        }
609    }
610    #[cfg(not(target_arch = "wasm32"))]
611    #[inline]
612    const fn routing_ref(&self) -> Option<&Routing> {
613        self.routing.as_ref()
614    }
615
616    // ── Migration accessors — match old flat-field names as methods ──
617    //
618    // These read through `request_ref()`, which is `const` on the host
619    // but not on wasm32 (it lazy-fetches via `get_or_init`). They
620    // therefore can't be `const fn` across all targets; the host-build
621    // `missing_const_for_fn` is allowed per-accessor.
622
623    /// The request HTTP method.
624    #[inline]
625    #[allow(clippy::missing_const_for_fn)]
626    pub fn method(&self) -> &http::Method {
627        &self.request_ref().verb
628    }
629    /// The wire protocol the request arrived on.
630    #[inline]
631    #[allow(clippy::missing_const_for_fn)]
632    pub fn protocol(&self) -> crate::schema::Protocol {
633        self.request_ref().protocol
634    }
635    /// The request path (app-prefix included).
636    #[inline]
637    pub fn path(&self) -> &str {
638        &self.request_ref().path
639    }
640    /// The raw request body bytes.
641    #[inline]
642    #[allow(clippy::missing_const_for_fn)]
643    pub fn body(&self) -> &Bytes {
644        &self.request_ref().body
645    }
646    /// The inbound request headers.
647    #[inline]
648    #[allow(clippy::missing_const_for_fn)]
649    pub fn headers(&self) -> &http::HeaderMap {
650        &self.request_ref().headers
651    }
652    /// The routing block, when the router has populated it. `None`
653    /// only on raw-request contexts that bypassed the router (e.g.
654    /// test fixtures, queue-worker dispatch). Most call sites should
655    /// reach for the flat accessors (`app_id()`, `resource_id()`,
656    /// `database()`, etc.) instead.
657    #[inline]
658    pub const fn routing(&self) -> Option<&Routing> {
659        self.routing.as_ref()
660    }
661
662    /// Mutable access to the query-param map. Used by routing code
663    /// that augments the parsed query with synthetic keys (`_search`,
664    /// path-extracted values, etc.) before downstream handlers
665    /// observe it.
666    #[inline]
667    pub const fn query_params_mut(&mut self) -> &mut HashMap<String, String> {
668        &mut self.request.query
669    }
670
671    /// The parsed query-param map.
672    #[inline]
673    #[allow(clippy::missing_const_for_fn)]
674    pub fn query_params(&self) -> &HashMap<String, String> {
675        &self.request_ref().query
676    }
677
678    /// Application id from routing, or empty `Arc<str>` when unrouted.
679    #[inline]
680    pub fn app_id(&self) -> Arc<str> {
681        self.routing_ref()
682            .map_or_else(|| Arc::from(""), |r| Arc::clone(&r.app))
683    }
684    /// Resource (table) id from routing, or empty `Arc<str>` when unrouted.
685    #[inline]
686    pub fn resource_id(&self) -> Arc<str> {
687        self.routing_ref()
688            .map_or_else(|| Arc::from(""), |r| Arc::clone(&r.resource))
689    }
690    /// Record key from routing, or `""` for a collection-level operation.
691    #[inline]
692    pub fn path_id(&self) -> &str {
693        self.routing_ref()
694            .and_then(|r| r.key.as_deref())
695            .unwrap_or("")
696    }
697    /// `true` when this is a collection-level operation (no record key).
698    #[inline]
699    pub fn is_collection(&self) -> bool {
700        self.routing_ref().is_none_or(|r| r.key.is_none())
701    }
702    /// Database name. Settled at request-routing time from
703    /// `TableDefinition.database` (or the default `"data"` when the
704    /// table doesn't specify). **Distinct from `app_id`** — the
705    /// `@database` schema directive can point a table at a database
706    /// shared across apps. Returns empty string when no routing.
707    #[inline]
708    pub fn database(&self) -> Arc<str> {
709        self.routing_ref()
710            .map_or_else(|| Arc::from(""), |r| Arc::clone(&r.database))
711    }
712    /// Table name. For table-backed resources the resource name IS the
713    /// table name.
714    #[inline]
715    pub fn table_name(&self) -> Arc<str> {
716        self.routing_ref()
717            .map_or_else(|| Arc::from(""), |r| Arc::clone(&r.resource))
718    }
719
720    // ── Mutators (for setter call sites) ──
721
722    /// Set the request method.
723    pub fn set_method(&mut self, m: http::Method) {
724        self.request.verb = m;
725    }
726    /// Set the request path.
727    pub fn set_path(&mut self, p: impl Into<EcoString>) {
728        self.request.path = p.into();
729    }
730    /// Set the request body.
731    pub fn set_body(&mut self, b: Bytes) {
732        self.request.body = b;
733    }
734    /// Set the request headers.
735    pub fn set_headers(&mut self, h: http::HeaderMap) {
736        self.request.headers = Arc::new(h);
737    }
738    /// Set the wire protocol.
739    pub const fn set_protocol(&mut self, p: crate::schema::Protocol) {
740        self.request.protocol = p;
741    }
742    /// Replace the parsed query-param map.
743    pub fn set_query_params(&mut self, q: HashMap<String, String>) {
744        self.request.query = q;
745    }
746    /// Set the routing app id (creating a `Routing` block if absent).
747    pub fn set_app_id(&mut self, app_id: Arc<str>) {
748        match &mut self.routing {
749            Some(r) => r.app = app_id,
750            None => {
751                self.set_routing(Some(Routing {
752                    app: app_id,
753                    resource: Arc::from(""),
754                    database: Arc::from(""),
755                    key: None,
756                }));
757            },
758        }
759    }
760    /// Set the routing resource id (creating a `Routing` block if absent).
761    pub fn set_resource_id(&mut self, resource_id: Arc<str>) {
762        match &mut self.routing {
763            Some(r) => r.resource = resource_id,
764            None => {
765                self.set_routing(Some(Routing {
766                    app: Arc::from(""),
767                    resource: resource_id,
768                    database: Arc::from(""),
769                    key: None,
770                }));
771            },
772        }
773    }
774    /// Set the routing record key (empty string clears it to collection-level).
775    pub fn set_path_id(&mut self, key: impl Into<String>) {
776        let key_s = key.into();
777        let key_opt = if key_s.is_empty() { None } else { Some(key_s) };
778        match &mut self.routing {
779            Some(r) => r.key = key_opt,
780            None => {
781                self.set_routing(Some(Routing {
782                    app: Arc::from(""),
783                    resource: Arc::from(""),
784                    database: Arc::from(""),
785                    key: key_opt,
786                }));
787            },
788        }
789    }
790    /// Clear the record key when `is_coll` is `true` (makes the op collection-level).
791    pub fn set_is_collection(&mut self, is_coll: bool) {
792        if is_coll && let Some(r) = &mut self.routing {
793            r.key = None;
794        }
795        // Setting is_collection=false without a key is meaningless; ignore.
796    }
797    /// Set the `database` field on the current `Routing` (creating a
798    /// `Routing` block if none exists). Called by the router after it
799    /// resolves the target table's `@database` directive.
800    pub fn set_database(&mut self, db: Arc<str>) {
801        match &mut self.routing {
802            Some(r) => r.database = db,
803            None => {
804                self.set_routing(Some(Routing {
805                    app: Arc::from(""),
806                    resource: Arc::from(""),
807                    database: db,
808                    key: None,
809                }));
810            },
811        }
812    }
813    /// Set the routing table name (aliases the resource id for table-backed resources).
814    pub fn set_table_name(&mut self, name: Arc<str>) {
815        // Table name aliases resource for table-backed resources.
816        match &mut self.routing {
817            Some(r) => r.resource = name,
818            None => {
819                self.set_routing(Some(Routing {
820                    app: Arc::from(""),
821                    resource: name,
822                    database: Arc::from(""),
823                    key: None,
824                }));
825            },
826        }
827    }
828
829    // ── Convenience accessors carried over from the old shape ──
830
831    /// Root directory accessor.
832    #[inline]
833    pub fn root_dir(&self) -> &str {
834        &self.root_directory
835    }
836
837    /// Application ID as `&str`.
838    #[inline]
839    pub fn app_id_str(&self) -> &str {
840        self.routing.as_ref().map_or("", |r| &r.app)
841    }
842
843    /// Resource ID as `&str` — borrow-returning counterpart to
844    /// `resource_id()` (which returns an owned `Arc<str>`).
845    #[inline]
846    pub fn resource_id_str(&self) -> &str {
847        self.routing.as_ref().map_or("", |r| &r.resource)
848    }
849
850    /// `PubSub` manager for a named table.
851    pub fn pubsub(&self, table_name: &str) -> Option<Arc<crate::pubsub::PubSubManager>> {
852        self.pubsub_lookup.as_ref().and_then(|f| f(table_name))
853    }
854
855    /// Raw table backend accessor.
856    pub fn backend(&self, name: &str) -> Option<Arc<dyn crate::backend::KvBackend>> {
857        self.backend_manager
858            .as_ref()
859            .and_then(|bm| bm.get_backend_for_table(name).ok())
860    }
861
862    /// Raw table backend accessor that errors when missing.
863    ///
864    /// # Errors
865    /// `YetiError::Internal` when no backend is attached or registered for `name`.
866    pub fn require_backend(&self, name: &str) -> Result<Arc<dyn crate::backend::KvBackend>> {
867        self.backend_manager
868            .as_ref()
869            .ok_or_else(|| YetiError::Internal("backend_manager not available".to_owned()))?
870            .get_backend_for_table(name)
871    }
872
873    /// A named query parameter, or `None` when absent.
874    #[inline]
875    pub fn query(&self, name: &str) -> Option<&str> {
876        self.request.query.get(name).map(String::as_str)
877    }
878
879    /// A named query parameter parsed as `i64`, falling back to `default`.
880    #[inline]
881    pub fn query_int(&self, name: &str, default: i64) -> i64 {
882        self.query(name)
883            .and_then(|v| v.parse().ok())
884            .unwrap_or(default)
885    }
886
887    /// A named query parameter parsed as a bool (`true`/`1`/`yes`/`on`),
888    /// falling back to `default`.
889    #[inline]
890    pub fn query_bool(&self, name: &str, default: bool) -> bool {
891        self.query(name).map_or(default, |v| {
892            matches!(v.to_lowercase().as_str(), "true" | "1" | "yes" | "on")
893        })
894    }
895
896    /// Get the database and table name as a pair (for permission checks).
897    /// Database comes from `routing.database` (settled at routing time
898    /// from the table's `@database` directive); table comes from
899    /// `routing.resource`.
900    #[inline]
901    pub fn table_context(&self) -> (&str, &str) {
902        self.routing
903            .as_ref()
904            .map_or(("", ""), |r| (&r.database, &r.resource))
905    }
906
907    /// Database name as `&str`. See [`Self::database`] for the full
908    /// distinction between `database` and `app_id`.
909    #[inline]
910    pub fn database_str(&self) -> &str {
911        self.routing.as_ref().map_or("", |r| &r.database)
912    }
913
914    /// Table name as `&str`. For table-backed resources, the resource name
915    /// IS the table name.
916    #[inline]
917    pub fn table_name_str(&self) -> &str {
918        self.routing.as_ref().map_or("", |r| &r.resource)
919    }
920
921    /// The request body parsed as JSON — lazily on first call, cached
922    /// thereafter. `None` for an empty or non-JSON body. Requests that
923    /// never read it (e.g. public/super-user writes that skip
924    /// attribute-level permission checks) never pay the parse. Goes
925    /// through `body()` so the wasm lazy backing materializes `request`
926    /// first.
927    #[must_use]
928    pub fn json_body(&self) -> Option<&serde_json::Value> {
929        self.json_body
930            .get_or_init(|| {
931                let body = self.body();
932                if body.is_empty() {
933                    None
934                } else {
935                    serde_json::from_slice::<serde_json::Value>(body.as_ref())
936                        .ok()
937                        .map(Box::new)
938                }
939            })
940            .as_deref()
941    }
942
943    /// Force the lazy [`Context::json_body`] parse now. Useful when a
944    /// later `&self` reader shouldn't pay the cost, or to pre-warm the
945    /// cache. No-op once cached or for an empty body.
946    pub fn parse_body(&mut self) {
947        let _ = self.json_body();
948    }
949
950    // ── AuthBlock accessors — borrow-returning helpers for the
951    //    common read paths. Setters write directly into `ctx.auth.*`.
952    //    Callers needing the owned `Access` clone reach via `ctx.auth.access`.
953
954    /// Resolved [`Access`] for this request. Defaults to [`Access::None`]
955    /// when unauthenticated.
956    #[inline]
957    pub const fn access(&self) -> &Access {
958        &self.auth.access
959    }
960
961    /// Cached per-request [`TablePermission`] decision.
962    #[inline]
963    pub const fn permission(&self) -> &TablePermission {
964        &self.auth.permission
965    }
966
967    /// Wire-level authentication claim produced by an
968    /// `AuthProvider::authenticate()` call. `None` for unauthenticated
969    /// requests.
970    #[inline]
971    pub const fn auth_identity(&self) -> Option<&AuthIdentity> {
972        self.auth.identity_input.as_ref()
973    }
974
975    // ── Lineage accessors ────────────────────────────────────────────────
976    //
977    // The `lineage` field is eager-materialized at construction (small,
978    // near-universal reads — logging, request_id). Accessors here are
979    // the canonical surface; direct `ctx.lineage.*` access is migration
980    // debt counting down to zero.
981
982    /// Request id (`UUIDv7`) — stamped by the inbound router, propagates
983    /// through HLC + logs.
984    #[inline]
985    pub fn request_id(&self) -> &str {
986        &self.lineage.request_id
987    }
988
989    /// Parent request id when this request was triggered by another
990    /// (e.g. queue worker dispatching a job's child call).
991    #[inline]
992    pub fn parent_id(&self) -> Option<&str> {
993        self.lineage.parent.as_deref()
994    }
995
996    /// Cross-request correlation id (e.g. customer's `X-Correlation-Id`
997    /// header). `None` when no correlation was passed.
998    #[inline]
999    pub fn correlation_id(&self) -> Option<&str> {
1000        self.lineage.correlation.as_deref()
1001    }
1002
1003    /// Deployment hash — identifies the yeti-fabric node that produced
1004    /// this request. Defaults to `"local"` for single-tenant dev.
1005    #[inline]
1006    pub fn deployment(&self) -> &str {
1007        &self.lineage.deployment
1008    }
1009
1010    /// Hybrid Logical Clock stamp at request receipt. `Copy`, so
1011    /// returned by value.
1012    #[inline]
1013    #[must_use]
1014    pub const fn hlc(&self) -> Hlc {
1015        self.lineage.hlc
1016    }
1017
1018    // ── Identity accessors ───────────────────────────────────────────────
1019    //
1020    // Eager-materialized at construction. `identity` is `Option<Identity>`
1021    // — the accessor exposes it as `Option<&Identity>` so call sites can
1022    // chain `.map(...)` without cloning, or pattern-match with
1023    // `if let Some(id) = ctx.identity() { ... }`.
1024
1025    /// Resolved request identity — `None` for unauthenticated requests
1026    /// or paths that bypass the auth pipeline (public endpoints).
1027    #[inline]
1028    pub const fn identity(&self) -> Option<&Identity> {
1029        self.identity.as_ref()
1030    }
1031
1032    /// Convenience: the authenticated principal (username / email /
1033    /// service id) when the request resolved an identity. Equivalent
1034    /// to `ctx.identity().and_then(|i| i.principal.as_deref())`.
1035    #[inline]
1036    pub fn principal(&self) -> Option<&str> {
1037        self.identity.as_ref().and_then(|i| i.principal.as_deref())
1038    }
1039
1040    /// Convenience: the authenticated role set, or empty slice when
1041    /// unauthenticated. Always-safe-to-iterate shape; for permission
1042    /// checks prefer [`Self::permissions`] which carries the resolved
1043    /// `super_user` bit + per-resource ops mask.
1044    #[inline]
1045    pub fn roles(&self) -> &[String] {
1046        self.identity
1047            .as_ref()
1048            .map_or(&[][..], |i| i.roles.as_slice())
1049    }
1050
1051    /// Convenience: the resolved permission bundle (`super_user` +
1052    /// per-resource op mask). Returns a default-empty `Permissions`
1053    /// when unauthenticated so call sites can chain `.super_user`
1054    /// without an outer `Option` dance.
1055    #[inline]
1056    pub fn permissions(&self) -> Option<&Permissions> {
1057        self.identity.as_ref().map(|i| &i.permissions)
1058    }
1059
1060    /// Convenience: the authentication method (basic / jwt / oauth /
1061    /// api-key / service-token / none).
1062    #[inline]
1063    #[must_use]
1064    pub fn auth_method(&self) -> AuthMethod {
1065        self.identity
1066            .as_ref()
1067            .map_or(AuthMethod::None, |i| i.auth_method)
1068    }
1069
1070    // ── Lineage setters ─────────────────────────────────────────────────
1071    //
1072    // Test fixtures + dispatch glue need to stamp lineage fields after
1073    // construction. The setters keep the field-write surface contained
1074    // so the underlying `lineage` field can move to `pub(crate)`.
1075
1076    /// Set the request id.
1077    pub fn set_request_id(&mut self, id: impl Into<String>) {
1078        self.lineage.request_id = id.into();
1079    }
1080
1081    /// Set the parent request id.
1082    pub fn set_parent_id(&mut self, parent: Option<String>) {
1083        self.lineage.parent = parent;
1084    }
1085
1086    /// Set the cross-request correlation id.
1087    pub fn set_correlation_id(&mut self, c: Option<String>) {
1088        self.lineage.correlation = c;
1089    }
1090
1091    /// Set the deployment hash.
1092    pub fn set_deployment(&mut self, d: impl Into<String>) {
1093        self.lineage.deployment = d.into();
1094    }
1095
1096    /// Set the HLC stamp.
1097    pub const fn set_hlc(&mut self, hlc: Hlc) {
1098        self.lineage.hlc = hlc;
1099    }
1100
1101    // ── Identity / auth setters ─────────────────────────────────────────
1102    //
1103    // Auth pipeline middleware writes through these after resolving the
1104    // inbound credential into a User + Role. Test fixtures also stamp
1105    // these to simulate authenticated requests.
1106
1107    /// Set the resolved identity block.
1108    pub fn set_identity(&mut self, identity: Option<Identity>) {
1109        self.identity = identity;
1110    }
1111
1112    /// Set the routing block.
1113    pub fn set_routing(&mut self, routing: Option<Routing>) {
1114        self.routing = routing;
1115    }
1116
1117    /// Set the resolved [`Access`].
1118    pub fn set_access(&mut self, access: Access) {
1119        self.auth.access = access;
1120    }
1121
1122    /// Set the cached [`TablePermission`] decision.
1123    pub fn set_permission(&mut self, p: TablePermission) {
1124        self.auth.permission = p;
1125    }
1126
1127    /// Set the wire-level authentication claim.
1128    pub fn set_auth_identity(&mut self, id: Option<AuthIdentity>) {
1129        self.auth.identity_input = id;
1130    }
1131
1132    /// Mutable [`Access`] for the auth pipeline's save/restore pattern:
1133    /// `std::mem::take(ctx.access_mut())` swaps in `Access::default()`
1134    /// so the resolver runs against a clean slate, then restoration
1135    /// writes back via [`Self::set_access`].
1136    #[inline]
1137    pub const fn access_mut(&mut self) -> &mut Access {
1138        &mut self.auth.access
1139    }
1140
1141    /// Mutable wire-level authentication claim (companion to
1142    /// [`Self::access_mut`] for the same save/restore pattern).
1143    #[inline]
1144    pub const fn auth_identity_mut(&mut self) -> &mut Option<AuthIdentity> {
1145        &mut self.auth.identity_input
1146    }
1147}