Skip to main content

pharos_native/runtime/
feature_runtime.rs

1//! Runtime query and rendering support for compiled declarative features.
2
3pub(super) mod logical;
4mod media_input;
5use std::collections::BTreeMap;
6#[cfg(not(target_os = "wasi"))]
7use std::collections::BTreeSet;
8use std::fs;
9use std::path::{Path, PathBuf};
10#[cfg(not(target_os = "wasi"))]
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use serde_json::{Map, Value};
14
15use crate::config::AppRuntime;
16
17#[cfg(not(target_os = "wasi"))]
18const MAX_QUERY_FILTERS: usize = 32;
19const MAX_QUERY_ROWS: usize = 100;
20
21#[derive(Debug, Clone, Eq, PartialEq)]
22/// Stable failure set returned by feature loading, validation, rendering, and execution.
23///
24/// # Variants
25///
26/// - `InvalidContract`: Supported `FeatureRuntimeError` variant.
27/// - `InvalidInput`: Supported `FeatureRuntimeError` variant.
28/// - `ValidationReport`: Supported `FeatureRuntimeError` variant.
29/// - `MissingRecord`: Supported `FeatureRuntimeError` variant.
30/// - `Conflict`: Supported `FeatureRuntimeError` variant.
31/// - `DatabaseUnavailable`: Supported `FeatureRuntimeError` variant.
32/// - `RenderFailed`: Supported `FeatureRuntimeError` variant.
33///
34/// # Examples
35///
36/// ```rust,ignore
37/// let _ = pharos_native::runtime::feature_runtime::FeatureRuntimeError::InvalidContract;
38/// ```
39///
40/// # Related
41///
42/// - [`crate::runtime::feature_runtime`] for constructors, helpers, and neighboring runtime types.
43pub enum FeatureRuntimeError {
44    InvalidContract(String),
45    InvalidInput(String),
46    ValidationReport(String),
47    MissingRecord(String),
48    Conflict(String),
49    DatabaseUnavailable,
50    /// Classified failure from the configured generic database authority.
51    Database(pharos_runtime_database::operations::DatabaseError),
52    RenderFailed(String),
53}
54
55#[derive(Debug, Clone, Default)]
56/// Request-scoped values exposed to feature queries and actions.
57///
58/// # Fields
59///
60/// - `request_target`: Original app-relative target used for declared action effects.
61/// - `actor_id`: Stored field on `FeatureRequestContext`.
62/// - `input`: Stored field on `FeatureRequestContext`.
63/// - `path_params`: Stored field on `FeatureRequestContext`.
64/// - `query_params`: Stored field on `FeatureRequestContext`.
65/// - `bindings`: Stored field on `FeatureRequestContext`.
66/// - `row`: Stored field on `FeatureRequestContext`.
67/// - `generated`: Stored field on `FeatureRequestContext`.
68/// - `protected`: Stored field on `FeatureRequestContext`.
69///
70/// # Examples
71///
72/// ```rust,ignore
73/// let _ = std::mem::size_of::<pharos_native::runtime::feature_runtime::FeatureRequestContext>();
74/// ```
75///
76/// # Related
77///
78/// - [`crate::runtime::feature_runtime`] for constructors, helpers, and neighboring runtime types.
79pub struct FeatureRequestContext {
80    pub request_target: String,
81    pub actor_id: i64,
82    pub input: Map<String, Value>,
83    pub path_params: Map<String, Value>,
84    pub query_params: Map<String, Value>,
85    pub bindings: Map<String, Value>,
86    pub row: Map<String, Value>,
87    pub generated: Map<String, Value>,
88    pub protected: Map<String, Value>,
89}
90
91#[derive(Debug, Clone, Eq, PartialEq)]
92/// Framework-neutral action result returned by feature execution.
93///
94/// # Fields
95///
96/// - `status`: Stored field on `FeatureActionOutcome`.
97/// - `fragment_html`: Stored field on `FeatureActionOutcome`.
98/// - `redirect`: Stored field on `FeatureActionOutcome`.
99///
100/// # Examples
101///
102/// ```rust,ignore
103/// let _ = std::mem::size_of::<pharos_native::runtime::feature_runtime::FeatureActionOutcome>();
104/// ```
105///
106/// # Related
107///
108/// - [`crate::runtime::feature_runtime`] for constructors, helpers, and neighboring runtime types.
109pub struct FeatureActionOutcome {
110    pub status: u16,
111    pub fragment_html: String,
112    pub redirect: String,
113    pub redirect_only: bool,
114}
115
116/// Resolves only declared path segments in a mutation redirect.
117fn action_redirect_path(
118    template: &str,
119    path_params: &Map<String, Value>,
120) -> Result<String, FeatureRuntimeError> {
121    if !template.starts_with('/')
122        || template.len() > 512
123        || template.contains(['?', '#', '\r', '\n', '\0'])
124    {
125        return Err(FeatureRuntimeError::InvalidContract(
126            "action redirect".to_owned(),
127        ));
128    }
129    let mut result = String::with_capacity(template.len());
130    for (index, segment) in template.split('/').enumerate() {
131        if index > 0 {
132            result.push('/');
133        }
134        let Some(parameter) = segment.strip_prefix(':') else {
135            if segment == ".." || segment.contains(['%', '\\']) {
136                return Err(FeatureRuntimeError::InvalidContract(
137                    "action redirect".to_owned(),
138                ));
139            }
140            result.push_str(segment);
141            continue;
142        };
143        let value = path_params
144            .get(parameter)
145            .ok_or_else(|| FeatureRuntimeError::InvalidContract(parameter.to_owned()))?;
146        let value = match value {
147            Value::String(value) => value.clone(),
148            Value::Number(value) => value.to_string(),
149            _ => return Err(FeatureRuntimeError::InvalidInput(parameter.to_owned())),
150        };
151        if value.is_empty() || value.len() > 256 || matches!(value.as_str(), "." | "..") {
152            return Err(FeatureRuntimeError::InvalidInput(parameter.to_owned()));
153        }
154        for byte in value.bytes() {
155            if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
156                result.push(char::from(byte));
157            } else {
158                result.push_str(&format!("%{byte:02X}"));
159            }
160        }
161    }
162    Ok(result)
163}
164
165#[derive(Debug, Clone, Eq, PartialEq)]
166/// Authorized private-asset location resolved from a declared ownership contract.
167///
168/// # Fields
169///
170/// - `private_root`: Stored field on `AuthorizedPrivateAsset`.
171/// - `asset_key`: Stored field on `AuthorizedPrivateAsset`.
172///
173/// # Examples
174///
175/// ```rust,ignore
176/// let _ = std::mem::size_of::<pharos_native::runtime::feature_runtime::AuthorizedPrivateAsset>();
177/// ```
178///
179/// # Related
180///
181/// - [`crate::runtime::feature_runtime`] for constructors, helpers, and neighboring runtime types.
182pub struct AuthorizedPrivateAsset {
183    pub private_root: String,
184    pub asset_key: String,
185}
186
187fn payment_provider_render_state_root(
188    app: &AppRuntime,
189    request_path: &str,
190) -> Result<Option<Value>, FeatureRuntimeError> {
191    let Some(app_root) = app.app_root.as_deref().map(Path::new) else {
192        return Ok(None);
193    };
194    let Some(management) = pharos_runtime_payment::payment_provider_manifest_config(app_root)
195    else {
196        return Ok(None);
197    };
198    let Some(provider_id) = pharos_runtime_payment::payment_provider_primary_provider(app_root)
199    else {
200        return Ok(None);
201    };
202    if !super::app_uses_file_backed_runtime_state(app) {
203        let store =
204            super::database::runtime_logical_app_store(app).map_err(logical::map_database_error)?;
205        let route_value =
206            pharos_runtime_payment::payment_provider_owner_route_value(app_root, request_path);
207        return pharos_runtime_database::payment::payment_render_state(
208            store.adapter.as_ref(),
209            &store.config,
210            &store.schema,
211            &management,
212            route_value.as_deref(),
213            &provider_id,
214        )
215        .map(Some)
216        .map_err(logical::map_database_error);
217    }
218    Ok(app
219        .state_path
220        .as_deref()
221        .and_then(|path| super::json_commands::json_doc_load_file(Path::new(path))))
222}
223
224/// Loads the effective roles for an authenticated actor.
225///
226/// # Parameters
227///
228/// - `app`: Input consumed by [`crate::runtime::feature_runtime::actor_roles`].
229/// - `actor_id`: Input consumed by [`crate::runtime::feature_runtime::actor_roles`].
230///
231/// # Returns
232///
233/// Returns the computed value produced by [`crate::runtime::feature_runtime::actor_roles`].
234///
235/// # Examples
236///
237/// ```rust,ignore
238/// let _result = pharos_native::runtime::feature_runtime::actor_roles(todo!(), 1);
239/// ```
240///
241/// # Related
242///
243/// - [`crate::runtime::feature_runtime`] for adjacent helpers that share the same runtime surface.
244pub fn actor_roles(
245    app: &AppRuntime,
246    actor_id: i64,
247) -> Result<Vec<String>, pharos_runtime_database::operations::DatabaseError> {
248    #[cfg(not(target_os = "wasi"))]
249    {
250        use pharos_runtime_database::operations::{Predicate, Query};
251
252        if actor_id <= 0 {
253            return Ok(Vec::new());
254        }
255        let store = super::database::runtime_logical_app_store(app)?;
256        let read_roles =
257            || -> Result<Vec<String>, pharos_runtime_database::operations::DatabaseError> {
258                let account = store
259                    .schema
260                    .entities
261                    .iter()
262                    .find(|entity| entity.name == "user_account")
263                    .ok_or(pharos_runtime_database::operations::DatabaseError::InvalidContract)?;
264                let admission = if account.fields.iter().any(|field| {
265                    field.name == "is_active"
266                        && field.kind == pharos_runtime_database::operations::FieldType::Boolean
267                }) {
268                    Predicate::Equal("is_active".to_owned(), Value::Bool(true))
269                } else if account.fields.iter().any(|field| {
270                    field.name == "account_status"
271                        && field.kind == pharos_runtime_database::operations::FieldType::Text
272                }) {
273                    Predicate::Equal(
274                        "account_status".to_owned(),
275                        Value::String("active".to_owned()),
276                    )
277                } else {
278                    return Err(
279                        pharos_runtime_database::operations::DatabaseError::InvalidContract,
280                    );
281                };
282                let active = store.adapter.query(
283                    &store.config,
284                    &store.schema,
285                    &Query {
286                        entity: "user_account".to_owned(),
287                        fields: vec!["id".to_owned()],
288                        filter: Some(Predicate::All(vec![
289                            Predicate::Equal("id".to_owned(), Value::from(actor_id)),
290                            admission,
291                        ])),
292                        order: Vec::new(),
293                        limit: 1,
294                        offset: 0,
295                    },
296                )?;
297                if active.is_empty() {
298                    return Ok(Vec::new());
299                }
300                let mut roles = std::collections::BTreeSet::new();
301                for (entity, actor_field, role_field) in [
302                    ("user_profile", "user_id", "account_type"),
303                    ("store_membership", "user_id", "role"),
304                ] {
305                    if !store
306                        .schema
307                        .entities
308                        .iter()
309                        .any(|declaration| declaration.name == entity)
310                    {
311                        continue;
312                    }
313                    let rows = store.adapter.query(
314                        &store.config,
315                        &store.schema,
316                        &Query {
317                            entity: entity.to_owned(),
318                            fields: vec![role_field.to_owned()],
319                            filter: Some(Predicate::Equal(
320                                actor_field.to_owned(),
321                                Value::from(actor_id),
322                            )),
323                            order: Vec::new(),
324                            limit: 65,
325                            offset: 0,
326                        },
327                    )?;
328                    if rows.len() > 64 {
329                        return Err(
330                            pharos_runtime_database::operations::DatabaseError::CapacityExceeded,
331                        );
332                    }
333                    for row in rows {
334                        let role = row.get(role_field).and_then(Value::as_str).ok_or(
335                            pharos_runtime_database::operations::DatabaseError::StorageFailure,
336                        )?;
337                        if sql_identifier(role).is_err() {
338                            return Err(
339                                pharos_runtime_database::operations::DatabaseError::InvalidInput,
340                            );
341                        }
342                        roles.insert(role.to_owned());
343                    }
344                }
345                Ok(roles.into_iter().collect())
346            };
347        read_roles()
348    }
349    #[cfg(target_os = "wasi")]
350    {
351        let _ = (app, actor_id);
352        Err(pharos_runtime_database::operations::DatabaseError::Unsupported)
353    }
354}
355
356/// Authorizes access to a private asset through the configured backend.
357///
358/// # Parameters
359///
360/// - `app`: Input consumed by [`crate::runtime::feature_runtime::authorize_private_asset`].
361/// - `request_path`: Input consumed by [`crate::runtime::feature_runtime::authorize_private_asset`].
362/// - `actor_id`: Input consumed by [`crate::runtime::feature_runtime::authorize_private_asset`].
363///
364/// # Returns
365///
366/// Returns the successful value produced by [`crate::runtime::feature_runtime::authorize_private_asset`] or an error when the operation fails.
367///
368/// # Errors
369///
370/// Returns an error value when [`crate::runtime::feature_runtime::authorize_private_asset`] cannot complete successfully.
371///
372/// # Examples
373///
374/// ```rust,ignore
375/// let _result = pharos_native::runtime::feature_runtime::authorize_private_asset(todo!(), "/", 1);
376/// ```
377///
378/// # Related
379///
380/// - [`crate::runtime::feature_runtime`] for adjacent helpers that share the same runtime surface.
381pub fn authorize_private_asset(
382    app: &AppRuntime,
383    request_path: &str,
384    actor_id: i64,
385) -> Result<Option<AuthorizedPrivateAsset>, FeatureRuntimeError> {
386    #[cfg(not(target_os = "wasi"))]
387    {
388        use pharos_runtime_database::operations::{Predicate, Query};
389
390        if actor_id <= 0 {
391            return Ok(None);
392        }
393        let root = artifact_root(app)?;
394        let contract_path = root.join("data/contracts/protected-assets.json");
395        if !contract_path.is_file() {
396            return Ok(None);
397        }
398        let contract = load_json(&contract_path)?;
399        let declarations = contract
400            .get("assets")
401            .and_then(Value::as_array)
402            .ok_or_else(|| FeatureRuntimeError::InvalidContract("protected assets".to_owned()))?;
403        let mut matched = None;
404        for declaration in declarations {
405            let pattern = declaration
406                .get("path")
407                .and_then(Value::as_str)
408                .ok_or_else(|| FeatureRuntimeError::InvalidContract("protected path".to_owned()))?;
409            if let Ok(parameters) = path_parameters(pattern, request_path)
410                && matched.replace((declaration, parameters)).is_some()
411            {
412                return Err(FeatureRuntimeError::InvalidContract(
413                    "ambiguous protected route".to_owned(),
414                ));
415            }
416        }
417        let Some((declaration, parameters)) = matched else {
418            return Ok(None);
419        };
420        let entity_id = declaration
421            .get("entity")
422            .and_then(Value::as_str)
423            .ok_or_else(|| FeatureRuntimeError::InvalidContract("protected entity".to_owned()))?;
424        let store =
425            super::database::runtime_logical_app_store(app).map_err(logical::map_database_error)?;
426        let entity = store
427            .schema
428            .entities
429            .iter()
430            .find(|entity| entity.name == entity_id)
431            .ok_or_else(|| FeatureRuntimeError::InvalidContract(entity_id.to_owned()))?;
432        let identity_fields = entity.identity.fields();
433        let identity_field = match identity_fields.as_slice() {
434            [field] => *field,
435            _ => {
436                return Err(FeatureRuntimeError::InvalidContract(
437                    "protected asset identity".to_owned(),
438                ));
439            }
440        };
441        let key_field = sql_identifier(
442            declaration
443                .get("key_field")
444                .and_then(Value::as_str)
445                .unwrap_or_default(),
446        )?;
447        let owner_field = sql_identifier(
448            declaration
449                .get("owner_field")
450                .and_then(Value::as_str)
451                .unwrap_or_default(),
452        )?;
453        let asset_field = sql_identifier(
454            declaration
455                .get("asset_field")
456                .and_then(Value::as_str)
457                .unwrap_or_default(),
458        )?;
459        if ![
460            identity_field,
461            key_field.as_str(),
462            owner_field.as_str(),
463            asset_field.as_str(),
464        ]
465        .iter()
466        .all(|name| entity.fields.iter().any(|field| field.name == *name))
467        {
468            return Err(FeatureRuntimeError::InvalidContract(entity_id.to_owned()));
469        }
470        let key = parameters
471            .values()
472            .next()
473            .and_then(Value::as_str)
474            .ok_or_else(|| FeatureRuntimeError::InvalidInput("protected key".to_owned()))?;
475        let query = Query {
476            entity: entity.name.clone(),
477            fields: vec![identity_field.to_owned(), asset_field.clone()],
478            filter: Some(Predicate::All(vec![
479                Predicate::Equal(key_field, Value::String(key.to_owned())),
480                Predicate::Equal(owner_field, Value::from(actor_id)),
481            ])),
482            order: Vec::new(),
483            limit: 1,
484            offset: 0,
485        };
486        let rows = store
487            .adapter
488            .query(&store.config, &store.schema, &query)
489            .map_err(|_| FeatureRuntimeError::DatabaseUnavailable)?;
490        let Some(row) = rows.first() else {
491            return Ok(None);
492        };
493        let protected_id = row
494            .get(identity_field)
495            .and_then(Value::as_i64)
496            .ok_or(FeatureRuntimeError::DatabaseUnavailable)?;
497        let asset_key = row
498            .get(&asset_field)
499            .and_then(Value::as_str)
500            .ok_or(FeatureRuntimeError::DatabaseUnavailable)?
501            .to_owned();
502        record_private_asset_authorization(app, declaration, actor_id, protected_id)?;
503        Ok(Some(AuthorizedPrivateAsset {
504            private_root: declaration
505                .get("root")
506                .and_then(Value::as_str)
507                .unwrap_or_default()
508                .to_owned(),
509            asset_key,
510        }))
511    }
512    #[cfg(target_os = "wasi")]
513    {
514        let _ = (app, request_path, actor_id);
515        Err(FeatureRuntimeError::DatabaseUnavailable)
516    }
517}
518
519/// Ensures a database identifier cannot change statement structure.
520fn sql_identifier(value: &str) -> Result<String, FeatureRuntimeError> {
521    if value.is_empty()
522        || value.len() > 128
523        || !value.bytes().enumerate().all(|(index, byte)| match byte {
524            b'a'..=b'z' | b'A'..=b'Z' | b'_' => true,
525            b'0'..=b'9' => index > 0,
526            _ => false,
527        })
528    {
529        return Err(FeatureRuntimeError::InvalidContract(value.to_owned()));
530    }
531    Ok(value.to_owned())
532}
533
534/// Loads one bounded JSON document from the built application artifact.
535fn load_json(path: &Path) -> Result<Value, FeatureRuntimeError> {
536    let text = std::fs::read_to_string(path)
537        .map_err(|_| FeatureRuntimeError::InvalidContract(path.to_string_lossy().into_owned()))?;
538    serde_json::from_str(&text)
539        .map_err(|_| FeatureRuntimeError::InvalidContract(path.to_string_lossy().into_owned()))
540}
541
542fn manifest_declared_template_params(
543    app: &AppRuntime,
544) -> Result<Map<String, Value>, FeatureRuntimeError> {
545    let mut params = Map::new();
546    let app_root = artifact_root(app)?;
547    let manifest_path = app_root.join("pharos.app.json");
548    if !manifest_path.is_file() {
549        return Ok(params);
550    }
551    let manifest = load_json(&manifest_path)?;
552    let Some(payments) = manifest.get("payments").and_then(Value::as_object) else {
553        return Ok(params);
554    };
555    if let Some(mode) = payments.get("checkout_mode").and_then(Value::as_str) {
556        if mode.len() > 64 {
557            return Err(FeatureRuntimeError::InvalidContract(
558                "payments.checkout_mode".to_owned(),
559            ));
560        }
561        let normalized_mode = match mode.trim().to_ascii_lowercase().as_str() {
562            "live" => "live",
563            _ => "demo",
564        };
565        let display_mode = if normalized_mode == "live" {
566            "Live"
567        } else {
568            "Demo"
569        };
570        let mode_class = if normalized_mode == "live" {
571            "payment-mode-live"
572        } else {
573            "payment-mode-demo"
574        };
575        params.insert(
576            "payment_checkout_mode".to_owned(),
577            Value::String(display_mode.to_owned()),
578        );
579        params.insert(
580            "payment_checkout_mode_value".to_owned(),
581            Value::String(normalized_mode.to_owned()),
582        );
583        params.insert(
584            "payment_checkout_mode_class".to_owned(),
585            Value::String(mode_class.to_owned()),
586        );
587        params.insert(
588            "payment_checkout_demo_checked".to_owned(),
589            Value::String(
590                if normalized_mode == "demo" {
591                    "checked"
592                } else {
593                    ""
594                }
595                .to_owned(),
596            ),
597        );
598        params.insert(
599            "payment_checkout_live_checked".to_owned(),
600            Value::String(
601                if normalized_mode == "live" {
602                    "checked"
603                } else {
604                    ""
605                }
606                .to_owned(),
607            ),
608        );
609        params.insert(
610            "payment_checkout_notice_class".to_owned(),
611            Value::String(
612                if normalized_mode == "live" {
613                    "checkout-notice-hidden"
614                } else {
615                    "demo-notice"
616                }
617                .to_owned(),
618            ),
619        );
620        params.insert(
621            "payment_checkout_decline_class".to_owned(),
622            Value::String(
623                if normalized_mode == "live" {
624                    "checkout-action-hidden"
625                } else {
626                    ""
627                }
628                .to_owned(),
629            ),
630        );
631        if normalized_mode == "live" {
632            params.insert(
633                "checkout_notice_title".to_owned(),
634                Value::String(String::new()),
635            );
636            params.insert(
637                "checkout_notice_body".to_owned(),
638                Value::String(String::new()),
639            );
640            params.insert(
641                "checkout_primary_action".to_owned(),
642                Value::String("Purchase".to_owned()),
643            );
644        }
645    }
646    params.insert(
647        "payment_provider_credentials_state".to_owned(),
648        Value::String("not_saved".to_owned()),
649    );
650    params.insert(
651        "payment_provider_credentials_state_display".to_owned(),
652        Value::String("Not saved".to_owned()),
653    );
654    params.insert(
655        "payment_provider_feedback_message".to_owned(),
656        Value::String(String::new()),
657    );
658    if let Some(provider) = payments
659        .get("supported_providers")
660        .and_then(Value::as_array)
661        .into_iter()
662        .flatten()
663        .find_map(Value::as_str)
664    {
665        if provider.len() > 64 {
666            return Err(FeatureRuntimeError::InvalidContract(
667                "payments.supported_providers".to_owned(),
668            ));
669        }
670        params.insert(
671            "payment_primary_provider".to_owned(),
672            Value::String(provider.to_owned()),
673        );
674    }
675    if let Some(copy) = payments.get("copy").and_then(Value::as_object) {
676        if copy.len() > 64 {
677            return Err(FeatureRuntimeError::InvalidContract(
678                "payments.copy".to_owned(),
679            ));
680        }
681        for (key, value) in copy {
682            let key = sql_identifier(key)?;
683            let scalar = match value {
684                Value::String(text) if text.len() <= 4096 => Value::String(text.clone()),
685                Value::Bool(flag) => Value::Bool(*flag),
686                Value::Number(number) => Value::Number(number.clone()),
687                Value::Null => Value::Null,
688                _ => {
689                    return Err(FeatureRuntimeError::InvalidContract(format!(
690                        "payments.copy.{key}"
691                    )));
692                }
693            };
694            params.insert(key, scalar);
695        }
696    }
697    if let Some(copy) = super::payment_provider::payment_provider_copy_entries(&app_root) {
698        if copy.len() > 64 {
699            return Err(FeatureRuntimeError::InvalidContract(
700                "payments.provider_src".to_owned(),
701            ));
702        }
703        for (key, value) in copy {
704            params.insert(sql_identifier(&key)?, value);
705        }
706    }
707    Ok(params)
708}
709
710/// Resolves the built artifact root used by feature contracts and templates.
711fn artifact_root(app: &AppRuntime) -> Result<PathBuf, FeatureRuntimeError> {
712    app.artifact_dir
713        .as_deref()
714        .filter(|value| !value.is_empty())
715        .map(PathBuf::from)
716        .ok_or(FeatureRuntimeError::DatabaseUnavailable)
717}
718
719/// Loads all declared relational entities and their allowed fields.
720/// Reads a dotted object path without permitting array or prototype-style traversal.
721fn dotted_value<'a>(root: &'a Value, path: &str) -> Option<&'a Value> {
722    path.split('.').try_fold(root, |current, segment| {
723        (!segment.is_empty()).then_some(())?;
724        current.as_object()?.get(segment)
725    })
726}
727
728/// Resolves one declared request, subject, binding, or literal value.
729fn resolve_value(
730    declaration: &Value,
731    context: &FeatureRequestContext,
732) -> Result<Option<Value>, FeatureRuntimeError> {
733    resolve_value_with_budget(declaration, context, 32)
734}
735
736/// Bounds recursive declared value evaluation before resolving request or query bindings.
737fn resolve_value_with_budget(
738    declaration: &Value,
739    context: &FeatureRequestContext,
740    remaining: usize,
741) -> Result<Option<Value>, FeatureRuntimeError> {
742    if remaining == 0 {
743        return Err(FeatureRuntimeError::InvalidContract(
744            "value nesting limit".into(),
745        ));
746    }
747    if !declaration.is_object() && !declaration.is_string() {
748        return Ok(Some(declaration.clone()));
749    }
750    let roots = serde_json::json!({
751        "input": context.input,
752        "path": context.path_params,
753        "query_param": context.query_params,
754        "query": context.bindings,
755        "row": context.row,
756        "generated": context.generated,
757        "protected": context.protected,
758        "subject": {"user_id": context.actor_id},
759    });
760    if let Some(text) = declaration.as_str() {
761        if text == "server.now" {
762            return Ok(Some(Value::String(super::current_timestamp_utc())));
763        }
764        if text == "server.date" {
765            return Ok(Some(Value::String(
766                super::current_timestamp_utc()[..10].to_owned(),
767            )));
768        }
769        for prefix in [
770            "input.",
771            "path.",
772            "query_param.",
773            "query.",
774            "row.",
775            "generated.",
776            "protected.",
777            "subject.",
778        ] {
779            if text.starts_with(prefix) {
780                return Ok(dotted_value(&roots, text).cloned());
781            }
782        }
783        return Ok(Some(Value::String(text.to_owned())));
784    }
785    let object = declaration
786        .as_object()
787        .ok_or_else(|| FeatureRuntimeError::InvalidContract("value source".to_owned()))?;
788    if let Some(offset) = object.get("server_time_offset_seconds") {
789        let seconds = offset
790            .as_i64()
791            .filter(|seconds| (-31_622_400..=31_622_400).contains(seconds))
792            .filter(|_| object.len() == 1)
793            .ok_or_else(|| {
794                FeatureRuntimeError::InvalidContract("server_time_offset_seconds".into())
795            })?;
796        let now = super::runtime_timestamp_seconds(&super::current_timestamp_utc())
797            .and_then(|now| now.checked_add_signed(seconds))
798            .ok_or_else(|| {
799                FeatureRuntimeError::InvalidContract("server_time_offset_seconds".into())
800            })?;
801        return Ok(Some(Value::String(
802            pharos_runtime_core::time::timestamp_from_epoch_seconds(now),
803        )));
804    }
805    if let Some(spec) = object.get("pluck").and_then(Value::as_object) {
806        let field = spec
807            .get("field")
808            .and_then(Value::as_str)
809            .ok_or_else(|| FeatureRuntimeError::InvalidContract("pluck.field".into()))?;
810        sql_identifier(field)?;
811        let rows = resolve_value_with_budget(
812            spec.get("source")
813                .ok_or_else(|| FeatureRuntimeError::InvalidContract("pluck.source".into()))?,
814            context,
815            remaining - 1,
816        )?
817        .ok_or_else(|| FeatureRuntimeError::InvalidInput("pluck.source".into()))?;
818        let rows = rows
819            .as_array()
820            .filter(|rows| rows.len() <= 64)
821            .ok_or_else(|| FeatureRuntimeError::InvalidInput("pluck.source".into()))?;
822        let values = rows
823            .iter()
824            .map(|row| {
825                row.as_object()
826                    .and_then(|row| row.get(field))
827                    .cloned()
828                    .ok_or_else(|| FeatureRuntimeError::InvalidInput("pluck.field".into()))
829            })
830            .collect::<Result<Vec<_>, _>>()?;
831        return Ok(Some(Value::Array(values)));
832    }
833    if let Some(spec) = object.get("lookup").and_then(Value::as_object) {
834        let values = spec
835            .get("values")
836            .and_then(Value::as_object)
837            .filter(|values| !values.is_empty() && values.len() <= 64)
838            .ok_or_else(|| FeatureRuntimeError::InvalidContract("lookup.values".into()))?;
839        let key = resolve_value_with_budget(
840            spec.get("key")
841                .ok_or_else(|| FeatureRuntimeError::InvalidContract("lookup.key".into()))?,
842            context,
843            remaining - 1,
844        )?;
845        let Some(key) = key else {
846            return Ok(None);
847        };
848        let key = key
849            .as_str()
850            .filter(|key| key.len() <= 128)
851            .ok_or_else(|| FeatureRuntimeError::InvalidInput("lookup.key".into()))?;
852        if key.is_empty() {
853            return Ok(None);
854        }
855        let value = values
856            .get(key)
857            .ok_or_else(|| FeatureRuntimeError::InvalidInput("lookup.key".into()))?;
858        return if value.is_null() {
859            Ok(None)
860        } else {
861            resolve_value_with_budget(value, context, remaining - 1)
862        };
863    }
864    if let Some(values) = object.get("coalesce").and_then(Value::as_array) {
865        if values.is_empty() || values.len() > 8 {
866            return Err(FeatureRuntimeError::InvalidContract("coalesce".into()));
867        }
868        for value in values {
869            if let Some(value) = resolve_value_with_budget(value, context, remaining - 1)?
870                .filter(|value| !value.is_null())
871            {
872                return Ok(Some(value));
873            }
874        }
875        return Ok(None);
876    }
877    if let Some(values) = object.get("add").and_then(Value::as_array) {
878        if values.is_empty() || values.len() > 8 {
879            return Err(FeatureRuntimeError::InvalidContract("add".into()));
880        }
881        let mut total = 0_i64;
882        for value in values {
883            let value = resolve_value_with_budget(value, context, remaining - 1)?
884                .and_then(|value| value.as_i64())
885                .ok_or_else(|| FeatureRuntimeError::InvalidInput("add".into()))?;
886            total = total
887                .checked_add(value)
888                .ok_or_else(|| FeatureRuntimeError::Conflict("checked add".into()))?;
889        }
890        return Ok(Some(Value::from(total)));
891    }
892    if let Some(spec) = object.get("format_minor").and_then(Value::as_object) {
893        let amount = resolve_value_with_budget(
894            spec.get("amount").ok_or_else(|| {
895                FeatureRuntimeError::InvalidContract("format_minor.amount".into())
896            })?,
897            context,
898            remaining - 1,
899        )?
900        .and_then(|value| value.as_i64())
901        .filter(|amount| *amount >= 0)
902        .ok_or_else(|| FeatureRuntimeError::InvalidInput("format_minor.amount".into()))?;
903        let currency = resolve_value_with_budget(
904            spec.get("currency").ok_or_else(|| {
905                FeatureRuntimeError::InvalidContract("format_minor.currency".into())
906            })?,
907            context,
908            remaining - 1,
909        )?
910        .and_then(|value| value.as_str().map(str::to_owned))
911        .ok_or_else(|| FeatureRuntimeError::InvalidInput("format_minor.currency".into()))?;
912        let value = super::json_commands::currency_minor_text(amount, &currency)
913            .ok_or_else(|| FeatureRuntimeError::InvalidInput("format_minor.currency".into()))?;
914        return Ok(Some(Value::from(value)));
915    }
916    if let Some(value) = object.get("literal") {
917        return Ok(Some(value.clone()));
918    }
919    for (key, root_name) in [
920        ("input", "input"),
921        ("path", "path"),
922        ("query_param", "query_param"),
923        ("binding", "query"),
924        ("row", "row"),
925        ("generated", "generated"),
926        ("protected", "protected"),
927        ("subject", "subject"),
928    ] {
929        if let Some(field) = object.get(key).and_then(Value::as_str) {
930            return Ok(dotted_value(&roots, &format!("{root_name}.{field}")).cloned());
931        }
932    }
933    if object.get("server").and_then(Value::as_str) == Some("now") {
934        return Ok(Some(Value::String(super::current_timestamp_utc())));
935    }
936    if let Some(seconds) = object
937        .get("server_now_minus_seconds")
938        .and_then(Value::as_u64)
939    {
940        if seconds > 604_800 {
941            return Err(FeatureRuntimeError::InvalidContract(
942                "server_now_minus_seconds".to_owned(),
943            ));
944        }
945        return Ok(Some(Value::String(timestamp_before_seconds_utc(seconds))));
946    }
947    if let Some(spec) = object.get("pad_integer").and_then(Value::as_object) {
948        let width = spec
949            .get("width")
950            .and_then(Value::as_u64)
951            .filter(|width| (1..=19).contains(width))
952            .ok_or_else(|| FeatureRuntimeError::InvalidContract("pad_integer.width".to_owned()))?
953            as usize;
954        let value = resolve_value_with_budget(
955            spec.get("value").ok_or_else(|| {
956                FeatureRuntimeError::InvalidContract("pad_integer.value".to_owned())
957            })?,
958            context,
959            remaining - 1,
960        )?
961        .and_then(|value| value.as_i64())
962        .filter(|value| *value >= 0)
963        .ok_or_else(|| FeatureRuntimeError::InvalidInput("pad_integer.value".to_owned()))?;
964        let output = format!("{value:0width$}");
965        if output.len() > width {
966            return Err(FeatureRuntimeError::InvalidInput(
967                "pad_integer.width".to_owned(),
968            ));
969        }
970        return Ok(Some(Value::String(output)));
971    }
972    if let Some(parts) = object.get("concat").and_then(Value::as_array) {
973        if parts.is_empty() || parts.len() > 16 {
974            return Err(FeatureRuntimeError::InvalidContract("concat".to_owned()));
975        }
976        let mut output = String::new();
977        for part in parts {
978            let value = resolve_value_with_budget(part, context, remaining - 1)?
979                .ok_or_else(|| FeatureRuntimeError::InvalidInput("concat".to_owned()))?;
980            let text = value
981                .as_str()
982                .map(ToOwned::to_owned)
983                .or_else(|| value.as_i64().map(|number| number.to_string()))
984                .ok_or_else(|| FeatureRuntimeError::InvalidInput("concat".to_owned()))?;
985            if output.len().saturating_add(text.len()) > 512 {
986                return Err(FeatureRuntimeError::InvalidInput("concat".to_owned()));
987            }
988            output.push_str(&text);
989        }
990        return Ok(Some(Value::String(output)));
991    }
992    if let Some(sum) = object.get("sum").and_then(Value::as_object) {
993        let query = sum
994            .get("query")
995            .and_then(Value::as_str)
996            .ok_or_else(|| FeatureRuntimeError::InvalidContract("sum.query".to_owned()))?;
997        let field = sum
998            .get("field")
999            .and_then(Value::as_str)
1000            .ok_or_else(|| FeatureRuntimeError::InvalidContract("sum.field".to_owned()))?;
1001        sql_identifier(query)?;
1002        sql_identifier(field)?;
1003        let max_items = sum
1004            .get("max_items")
1005            .and_then(Value::as_u64)
1006            .unwrap_or(20)
1007            .min(MAX_QUERY_ROWS as u64) as usize;
1008        let rows = context
1009            .bindings
1010            .get(query)
1011            .and_then(Value::as_array)
1012            .ok_or_else(|| FeatureRuntimeError::MissingRecord(query.to_owned()))?;
1013        if rows.is_empty() || rows.len() > max_items {
1014            return Err(FeatureRuntimeError::Conflict("bounded sum".to_owned()));
1015        }
1016        let total = rows.iter().try_fold(0i64, |total, row| {
1017            row.get(field)
1018                .and_then(Value::as_i64)
1019                .and_then(|value| total.checked_add(value))
1020        });
1021        return total
1022            .map(Value::from)
1023            .map(Some)
1024            .ok_or_else(|| FeatureRuntimeError::Conflict("checked sum".to_owned()));
1025    }
1026    Err(FeatureRuntimeError::InvalidContract(
1027        "value source".to_owned(),
1028    ))
1029}
1030
1031/// Selects one contract variant using a previously validated scalar request value.
1032fn selected_contract_variant<'a>(
1033    contract: &'a Value,
1034    context: &FeatureRequestContext,
1035) -> Result<&'a Value, FeatureRuntimeError> {
1036    let Some(variants) = contract.get("variants").and_then(Value::as_object) else {
1037        return Ok(contract);
1038    };
1039    if variants.is_empty() || variants.len() > 16 {
1040        return Err(FeatureRuntimeError::InvalidContract(
1041            "contract variants".to_owned(),
1042        ));
1043    }
1044    let selector = contract
1045        .get("select")
1046        .and_then(Value::as_str)
1047        .ok_or_else(|| FeatureRuntimeError::InvalidContract("contract select".to_owned()))?;
1048    let selected = resolve_value(&Value::String(selector.to_owned()), context)?
1049        .and_then(|value| value.as_str().map(ToOwned::to_owned))
1050        .ok_or_else(|| FeatureRuntimeError::InvalidInput("contract select".to_owned()))?;
1051    sql_identifier(&selected)?;
1052    variants
1053        .get(&selected)
1054        .ok_or_else(|| FeatureRuntimeError::InvalidInput("contract variant".to_owned()))
1055}
1056
1057/// Collects every named query binding referenced by a transaction declaration.
1058#[cfg(not(target_os = "wasi"))]
1059/// Performs referenced queries.
1060fn referenced_queries(value: &Value, output: &mut BTreeSet<String>) {
1061    match value {
1062        Value::Array(items) => {
1063            for item in items {
1064                referenced_queries(item, output);
1065            }
1066        }
1067        Value::Object(fields) => {
1068            if let Some(query) = fields.get("query").and_then(Value::as_str) {
1069                output.insert(query.to_owned());
1070            }
1071            if let Some(binding) = fields.get("binding").and_then(Value::as_str)
1072                && let Some(query) = binding.split('.').next()
1073                && !query.is_empty()
1074            {
1075                output.insert(query.to_owned());
1076            }
1077            for item in fields.values() {
1078                referenced_queries(item, output);
1079            }
1080        }
1081        Value::String(text) => {
1082            if let Some(binding) = text.strip_prefix("query.")
1083                && let Some(query) = binding.split('.').next()
1084                && !query.is_empty()
1085            {
1086                output.insert(query.to_owned());
1087            }
1088        }
1089        Value::Null | Value::Bool(_) | Value::Number(_) => {}
1090    }
1091}
1092
1093/// Collects named opaque values referenced by one transaction declaration.
1094#[cfg(not(target_os = "wasi"))]
1095/// Performs generated values.
1096fn generated_values(value: &Value, output: &mut BTreeSet<String>) {
1097    match value {
1098        Value::Array(items) => {
1099            for item in items {
1100                generated_values(item, output);
1101            }
1102        }
1103        Value::Object(fields) => {
1104            if let Some(name) = fields.get("generated").and_then(Value::as_str) {
1105                output.insert(name.to_owned());
1106            }
1107            for item in fields.values() {
1108                generated_values(item, output);
1109            }
1110        }
1111        Value::String(text) => {
1112            if let Some(name) = text.strip_prefix("generated.")
1113                && !name.is_empty()
1114            {
1115                output.insert(name.to_owned());
1116            }
1117        }
1118        Value::Null | Value::Bool(_) | Value::Number(_) => {}
1119    }
1120}
1121
1122/// Generates all transaction-scoped opaque values before the database transaction starts.
1123#[cfg(not(target_os = "wasi"))]
1124/// Performs populate generated values.
1125fn populate_generated_values(
1126    transaction: &Value,
1127    context: &mut FeatureRequestContext,
1128) -> Result<(), FeatureRuntimeError> {
1129    let mut names = BTreeSet::new();
1130    generated_values(transaction, &mut names);
1131    for name in names {
1132        sql_identifier(&name)?;
1133        let value = super::dynamic::random_secret_hex(16)
1134            .map_err(|_| FeatureRuntimeError::DatabaseUnavailable)?;
1135        context.generated.insert(name, Value::String(value));
1136    }
1137    Ok(())
1138}
1139
1140/// Returns one bounded validation report payload for a feature action rejection.
1141fn validation_report(
1142    action_id: &str,
1143    errors: Vec<String>,
1144    field_error: Option<(String, String, String)>,
1145    form_error: Option<(String, String, String)>,
1146) -> String {
1147    let mut root = Map::new();
1148    root.insert(
1149        "schema".to_owned(),
1150        Value::String("pharos.validation.report.v1".to_owned()),
1151    );
1152    root.insert(
1153        "report_id".to_owned(),
1154        Value::String("pharos.validation.report.v1".to_owned()),
1155    );
1156    root.insert("action_id".to_owned(), Value::String(action_id.to_owned()));
1157    root.insert(
1158        "errors".to_owned(),
1159        Value::Array(errors.into_iter().take(8).map(Value::String).collect()),
1160    );
1161    let mut field_errors = Vec::new();
1162    if let Some((field_id, code, message)) = field_error {
1163        let mut entry = Map::new();
1164        entry.insert("field_id".to_owned(), Value::String(field_id));
1165        entry.insert("rule_id".to_owned(), Value::String(code.clone()));
1166        entry.insert("code".to_owned(), Value::String(code));
1167        entry.insert("message".to_owned(), Value::String(message));
1168        field_errors.push(Value::Object(entry));
1169    }
1170    let mut form_errors = Vec::new();
1171    if let Some((rule_id, code, message)) = form_error {
1172        let mut entry = Map::new();
1173        entry.insert("rule_id".to_owned(), Value::String(rule_id));
1174        entry.insert("code".to_owned(), Value::String(code));
1175        entry.insert("message".to_owned(), Value::String(message));
1176        form_errors.push(Value::Object(entry));
1177    }
1178    root.insert("field_errors".to_owned(), Value::Array(field_errors));
1179    root.insert("form_errors".to_owned(), Value::Array(form_errors));
1180    Value::Object(root).to_string()
1181}
1182
1183fn validation_report_with_field_errors(
1184    action_id: &str,
1185    field_errors: Vec<(String, String, String)>,
1186) -> String {
1187    let errors = field_errors
1188        .iter()
1189        .take(8)
1190        .map(|(_, code, _)| Value::String(code.clone()))
1191        .collect::<Vec<_>>();
1192    let field_errors = field_errors
1193        .into_iter()
1194        .take(8)
1195        .map(|(field_id, code, message)| {
1196            let mut entry = Map::new();
1197            entry.insert("field_id".to_owned(), Value::String(field_id));
1198            entry.insert("rule_id".to_owned(), Value::String(code.clone()));
1199            entry.insert("code".to_owned(), Value::String(code));
1200            entry.insert("message".to_owned(), Value::String(message));
1201            Value::Object(entry)
1202        })
1203        .collect::<Vec<_>>();
1204    serde_json::json!({
1205        "schema": "pharos.validation.report.v1",
1206        "report_id": "pharos.validation.report.v1",
1207        "action_id": action_id,
1208        "errors": errors,
1209        "field_errors": field_errors,
1210        "form_errors": []
1211    })
1212    .to_string()
1213}
1214
1215fn feature_field_validation_code(rules: &Value, default_code: &str) -> String {
1216    rules
1217        .get("validation_code")
1218        .and_then(Value::as_str)
1219        .filter(|value| !value.is_empty())
1220        .filter(|value| value.len() <= 80)
1221        .filter(|value| {
1222            value
1223                .chars()
1224                .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
1225        })
1226        .unwrap_or(default_code)
1227        .to_owned()
1228}
1229
1230/// Converts one JSON scalar request value into the string input form used by validators.
1231fn json_scalar_input(value: &Value) -> Option<String> {
1232    match value {
1233        Value::String(value) => Some(value.clone()),
1234        Value::Number(value) => Some(value.to_string()),
1235        Value::Bool(value) => Some(value.to_string()),
1236        Value::Null => None,
1237        _ => None,
1238    }
1239}
1240
1241/// Resolves one request header without accepting folded lines.
1242fn request_header_value(request: &super::http::HttpRequest, header_name: &str) -> Option<String> {
1243    request.headers.lines().find_map(|line| {
1244        let (name, value) = line.split_once(':')?;
1245        name.trim()
1246            .eq_ignore_ascii_case(header_name)
1247            .then(|| value.trim().to_owned())
1248    })
1249}
1250
1251/// Performs scalar value to text.
1252fn scalar_value_to_text(value: &Value) -> String {
1253    match value {
1254        Value::Null => String::new(),
1255        Value::Bool(value) => value.to_string(),
1256        Value::Number(value) => value.to_string(),
1257        Value::String(value) => value.clone(),
1258        Value::Array(_) | Value::Object(_) => String::new(),
1259    }
1260}
1261
1262/// Applies bounded derived-input hooks before typed validation.
1263fn apply_derived_input_hooks(
1264    mut raw: BTreeMap<String, String>,
1265    request: &super::http::HttpRequest,
1266    declared: &Map<String, Value>,
1267    hook_contract: Option<&Value>,
1268) -> Result<BTreeMap<String, String>, FeatureRuntimeError> {
1269    let query_params = query_parameters(&request.target)?;
1270    let Some(rows) = hook_contract
1271        .and_then(|value| value.get("derived_inputs"))
1272        .and_then(Value::as_array)
1273    else {
1274        return Ok(raw);
1275    };
1276    if rows.len() > MAX_QUERY_FILTERS {
1277        return Err(FeatureRuntimeError::InvalidContract(
1278            "hook_contract.derived_inputs".to_owned(),
1279        ));
1280    }
1281    for row in rows {
1282        let field = row
1283            .get("field")
1284            .and_then(Value::as_str)
1285            .filter(|value| !value.is_empty() && value.len() <= 128)
1286            .ok_or_else(|| {
1287                FeatureRuntimeError::InvalidContract(
1288                    "hook_contract.derived_inputs.field".to_owned(),
1289                )
1290            })?;
1291        if !declared.contains_key(field) {
1292            return Err(FeatureRuntimeError::InvalidContract(
1293                "hook_contract.derived_inputs.field".to_owned(),
1294            ));
1295        }
1296        if row
1297            .get("preserve_existing")
1298            .and_then(Value::as_bool)
1299            .unwrap_or(false)
1300            && raw.get(field).is_some_and(|value| !value.is_empty())
1301        {
1302            continue;
1303        }
1304        let source = row
1305            .get("source")
1306            .and_then(Value::as_str)
1307            .unwrap_or_default();
1308        let derived = match source {
1309            "literal" => row
1310                .get("value")
1311                .map(scalar_value_to_text)
1312                .filter(|value| !value.is_empty()),
1313            "header" => row
1314                .get("header")
1315                .and_then(Value::as_str)
1316                .and_then(|header| request_header_value(request, header))
1317                .filter(|value| !value.is_empty()),
1318            "query" => row
1319                .get("param")
1320                .or_else(|| row.get("query_param"))
1321                .and_then(Value::as_str)
1322                .and_then(|field| query_params.get(field).cloned())
1323                .and_then(|value| value.as_str().map(ToOwned::to_owned))
1324                .filter(|value| !value.is_empty()),
1325            "form" => row
1326                .get("from_field")
1327                .or_else(|| row.get("input_field"))
1328                .and_then(Value::as_str)
1329                .and_then(|field| raw.get(field).cloned())
1330                .filter(|value| !value.is_empty()),
1331            "context_method" => (!request.method.is_empty()).then(|| request.method.clone()),
1332            "context_path" => request
1333                .target
1334                .split('?')
1335                .next()
1336                .filter(|value| !value.is_empty())
1337                .map(ToOwned::to_owned),
1338            _ => {
1339                return Err(FeatureRuntimeError::InvalidContract(
1340                    "hook_contract.derived_inputs.source".to_owned(),
1341                ));
1342            }
1343        };
1344        match derived {
1345            Some(value) => {
1346                raw.insert(field.to_owned(), value);
1347            }
1348            None => {
1349                raw.remove(field);
1350            }
1351        }
1352    }
1353    Ok(raw)
1354}
1355
1356/// Performs apply default input hooks.
1357fn apply_default_input_hooks(
1358    mut raw: BTreeMap<String, String>,
1359    request: &super::http::HttpRequest,
1360    declared: &Map<String, Value>,
1361    hook_contract: Option<&Value>,
1362) -> Result<BTreeMap<String, String>, FeatureRuntimeError> {
1363    let query_params = query_parameters(&request.target)?;
1364    let Some(rows) = hook_contract
1365        .and_then(|value| value.get("default_inputs"))
1366        .and_then(Value::as_array)
1367    else {
1368        return Ok(raw);
1369    };
1370    if rows.len() > MAX_QUERY_FILTERS {
1371        return Err(FeatureRuntimeError::InvalidContract(
1372            "hook_contract.default_inputs".to_owned(),
1373        ));
1374    }
1375    for row in rows {
1376        let field = row
1377            .get("field")
1378            .and_then(Value::as_str)
1379            .filter(|value| !value.is_empty() && value.len() <= 128)
1380            .ok_or_else(|| {
1381                FeatureRuntimeError::InvalidContract(
1382                    "hook_contract.default_inputs.field".to_owned(),
1383                )
1384            })?;
1385        if !declared.contains_key(field) {
1386            return Err(FeatureRuntimeError::InvalidContract(
1387                "hook_contract.default_inputs.field".to_owned(),
1388            ));
1389        }
1390        if raw.get(field).is_some_and(|value| !value.is_empty()) {
1391            continue;
1392        }
1393        let source = row
1394            .get("source")
1395            .and_then(Value::as_str)
1396            .unwrap_or_default();
1397        let derived = match source {
1398            "literal" => row.get("value").map(scalar_value_to_text),
1399            "header" => row
1400                .get("header")
1401                .and_then(Value::as_str)
1402                .and_then(|header| request_header_value(request, header))
1403                .filter(|value| !value.is_empty()),
1404            "query" => row
1405                .get("param")
1406                .or_else(|| row.get("query_param"))
1407                .and_then(Value::as_str)
1408                .and_then(|field| query_params.get(field).cloned())
1409                .and_then(|value| value.as_str().map(ToOwned::to_owned))
1410                .filter(|value| !value.is_empty()),
1411            "form" => row
1412                .get("from_field")
1413                .or_else(|| row.get("input_field"))
1414                .and_then(Value::as_str)
1415                .and_then(|field| raw.get(field).cloned())
1416                .filter(|value| !value.is_empty()),
1417            "context_method" => (!request.method.is_empty()).then(|| request.method.clone()),
1418            "context_path" => request
1419                .target
1420                .split('?')
1421                .next()
1422                .filter(|value| !value.is_empty())
1423                .map(ToOwned::to_owned),
1424            _ => {
1425                return Err(FeatureRuntimeError::InvalidContract(
1426                    "hook_contract.default_inputs.source".to_owned(),
1427                ));
1428            }
1429        };
1430        match derived {
1431            Some(value) => {
1432                raw.insert(field.to_owned(), value);
1433            }
1434            None => {
1435                raw.remove(field);
1436            }
1437        }
1438    }
1439    Ok(raw)
1440}
1441
1442/// Applies one bounded string-normalization hook sequence to validated action input.
1443fn apply_input_normalization_hooks(
1444    mut input: Map<String, Value>,
1445    hook_contract: Option<&Value>,
1446) -> Result<Map<String, Value>, FeatureRuntimeError> {
1447    let Some(hooks) = hook_contract
1448        .and_then(|value| value.get("normalize_inputs"))
1449        .and_then(Value::as_array)
1450    else {
1451        return Ok(input);
1452    };
1453    for hook in hooks {
1454        let kind = hook
1455            .get("kind")
1456            .and_then(Value::as_str)
1457            .ok_or_else(|| FeatureRuntimeError::InvalidContract("hook_contract.kind".to_owned()))?;
1458        let fields = hook
1459            .get("fields")
1460            .and_then(Value::as_array)
1461            .ok_or_else(|| {
1462                FeatureRuntimeError::InvalidContract("hook_contract.fields".to_owned())
1463            })?;
1464        for field in fields {
1465            let field = field.as_str().ok_or_else(|| {
1466                FeatureRuntimeError::InvalidContract("hook_contract.fields".to_owned())
1467            })?;
1468            if field.is_empty() || field.len() > 128 {
1469                return Err(FeatureRuntimeError::InvalidContract(
1470                    "hook_contract.fields".to_owned(),
1471                ));
1472            }
1473            let Some(current) = input.get(field).cloned() else {
1474                continue;
1475            };
1476            let Some(text) = current.as_str() else {
1477                continue;
1478            };
1479            let next = match kind {
1480                "trim" => Some(text.trim().to_owned()),
1481                "collapse_whitespace" => Some(
1482                    text.split_whitespace()
1483                        .filter(|segment| !segment.is_empty())
1484                        .collect::<Vec<_>>()
1485                        .join(" "),
1486                ),
1487                "lowercase" => Some(text.to_ascii_lowercase()),
1488                "uppercase" => Some(text.to_ascii_uppercase()),
1489                "empty_to_null" => (!text.trim().is_empty()).then_some(text.to_owned()),
1490                _ => {
1491                    return Err(FeatureRuntimeError::InvalidContract(
1492                        "hook_contract.kind".to_owned(),
1493                    ));
1494                }
1495            };
1496            match next {
1497                Some(value) => {
1498                    input.insert(field.to_owned(), Value::String(value));
1499                }
1500                None => {
1501                    input.remove(field);
1502                }
1503            }
1504        }
1505    }
1506    Ok(input)
1507}
1508
1509/// Parses and validates URL-encoded action fields against an inline input contract.
1510fn declared_input_preflight_errors(
1511    declared: &Map<String, Value>,
1512    raw: &BTreeMap<String, String>,
1513) -> Vec<(String, String, String)> {
1514    let mut errors = Vec::new();
1515    for (field, rules) in declared {
1516        let Some(value) = raw.get(field) else {
1517            continue;
1518        };
1519        if rules.get("type").and_then(Value::as_str) == Some("enum")
1520            && !rules
1521                .get("values")
1522                .and_then(Value::as_array)
1523                .is_some_and(|values| values.iter().any(|item| item.as_str() == Some(value)))
1524        {
1525            errors.push((
1526                field.clone(),
1527                feature_field_validation_code(rules, "field_invalid"),
1528                "The supplied value is not one of the declared enum choices.".to_owned(),
1529            ));
1530        }
1531        if let Some(other_field) = rules.get("after_field").and_then(Value::as_str)
1532            && let Some(other_value) = raw.get(other_field)
1533            && !value.is_empty()
1534            && !other_value.is_empty()
1535            && value <= other_value
1536        {
1537            errors.push((
1538                field.clone(),
1539                feature_field_validation_code(rules, "field_invalid"),
1540                "The supplied value must be after the referenced field.".to_owned(),
1541            ));
1542        }
1543    }
1544    errors
1545}
1546
1547fn validated_action_input(
1548    request: &super::http::HttpRequest,
1549    contract: &Value,
1550    hook_contract: Option<&Value>,
1551    action_id: &str,
1552) -> Result<Map<String, Value>, FeatureRuntimeError> {
1553    let large_text = contract
1554        .get("inline_fields")
1555        .and_then(Value::as_object)
1556        .is_some_and(|fields| {
1557            fields
1558                .values()
1559                .take(33)
1560                .any(|field| field.get("large_text") == Some(&Value::Bool(true)))
1561        });
1562    let body_limit = if large_text { 128 * 1024 } else { 64 * 1024 };
1563    if request.body.len() > body_limit || request.body_file_path.is_some() {
1564        return Err(FeatureRuntimeError::ValidationReport(validation_report(
1565            action_id,
1566            vec!["body_invalid".to_owned()],
1567            None,
1568            Some((
1569                "body_invalid".to_owned(),
1570                "body_invalid".to_owned(),
1571                "Request body must fit within the supported bounds.".to_owned(),
1572            )),
1573        )));
1574    }
1575    let declared = contract
1576        .get("inline_fields")
1577        .and_then(Value::as_object)
1578        .ok_or_else(|| FeatureRuntimeError::InvalidContract("input_contract".to_owned()))?;
1579    if declared.len() > MAX_QUERY_FILTERS {
1580        return Err(FeatureRuntimeError::InvalidContract(
1581            "input field count".to_owned(),
1582        ));
1583    }
1584    let mut raw = BTreeMap::new();
1585    if request
1586        .content_type
1587        .as_deref()
1588        .is_some_and(|value| value.starts_with("application/json"))
1589    {
1590        let body = std::str::from_utf8(&request.body).map_err(|_| {
1591            FeatureRuntimeError::ValidationReport(validation_report(
1592                action_id,
1593                vec!["json_object_required".to_owned()],
1594                None,
1595                Some((
1596                    "json_object_required".to_owned(),
1597                    "json_object_required".to_owned(),
1598                    "JSON request bodies must be valid top-level objects.".to_owned(),
1599                )),
1600            ))
1601        })?;
1602        let object = serde_json::from_str::<Value>(body)
1603            .ok()
1604            .and_then(|value| value.as_object().cloned())
1605            .ok_or_else(|| {
1606                FeatureRuntimeError::ValidationReport(validation_report(
1607                    action_id,
1608                    vec!["json_object_required".to_owned()],
1609                    None,
1610                    Some((
1611                        "json_object_required".to_owned(),
1612                        "json_object_required".to_owned(),
1613                        "JSON request bodies must be valid top-level objects.".to_owned(),
1614                    )),
1615                ))
1616            })?;
1617        for (key, value) in object {
1618            if !declared.contains_key(&key) {
1619                return Err(FeatureRuntimeError::ValidationReport(validation_report(
1620                    action_id,
1621                    vec!["undeclared_field".to_owned()],
1622                    Some((
1623                        key,
1624                        "undeclared_field".to_owned(),
1625                        "The request includes a field that is not part of the action contract."
1626                            .to_owned(),
1627                    )),
1628                    None,
1629                )));
1630            }
1631            let Some(value) = json_scalar_input(&value) else {
1632                return Err(FeatureRuntimeError::ValidationReport(validation_report(
1633                    action_id,
1634                    vec!["field_invalid".to_owned()],
1635                    Some((
1636                        key,
1637                        "field_invalid".to_owned(),
1638                        "Only scalar JSON values are accepted for declared input fields."
1639                            .to_owned(),
1640                    )),
1641                    None,
1642                )));
1643            };
1644            raw.insert(key, value);
1645        }
1646    } else {
1647        let body = std::str::from_utf8(&request.body).map_err(|_| {
1648            FeatureRuntimeError::ValidationReport(validation_report(
1649                action_id,
1650                vec!["body_invalid".to_owned()],
1651                None,
1652                Some((
1653                    "body_invalid".to_owned(),
1654                    "body_invalid".to_owned(),
1655                    "The request body could not be decoded.".to_owned(),
1656                )),
1657            ))
1658        })?;
1659        for pair in body.split('&').filter(|pair| !pair.is_empty()) {
1660            let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
1661            let key = super::json_commands::url_decode(key).ok_or_else(|| {
1662                FeatureRuntimeError::ValidationReport(validation_report(
1663                    action_id,
1664                    vec!["field_invalid".to_owned()],
1665                    None,
1666                    Some((
1667                        "field_invalid".to_owned(),
1668                        "field_invalid".to_owned(),
1669                        "The request contained an invalid field encoding.".to_owned(),
1670                    )),
1671                ))
1672            })?;
1673            let value = super::json_commands::url_decode(value).ok_or_else(|| {
1674                FeatureRuntimeError::ValidationReport(validation_report(
1675                    action_id,
1676                    vec!["field_invalid".to_owned()],
1677                    Some((
1678                        key.clone(),
1679                        "field_invalid".to_owned(),
1680                        "The request contained an invalid field encoding.".to_owned(),
1681                    )),
1682                    None,
1683                ))
1684            })?;
1685            if key == "csrf_token" {
1686                continue;
1687            }
1688            if !declared.contains_key(&key) || raw.insert(key.clone(), value).is_some() {
1689                return Err(FeatureRuntimeError::ValidationReport(validation_report(
1690                    action_id,
1691                    vec!["undeclared_field".to_owned()],
1692                    Some((
1693                        key,
1694                        "undeclared_field".to_owned(),
1695                        "The request includes a field that is not part of the action contract."
1696                            .to_owned(),
1697                    )),
1698                    None,
1699                )));
1700            }
1701        }
1702    }
1703    raw = apply_derived_input_hooks(raw, request, declared, hook_contract)?;
1704    raw = apply_default_input_hooks(raw, request, declared, hook_contract)?;
1705    let preflight_errors = declared_input_preflight_errors(declared, &raw);
1706    if !preflight_errors.is_empty() {
1707        return Err(FeatureRuntimeError::ValidationReport(
1708            validation_report_with_field_errors(action_id, preflight_errors),
1709        ));
1710    }
1711    let mut input = Map::new();
1712    for (field, rules) in declared {
1713        sql_identifier(field)?;
1714        let required = rules
1715            .get("required")
1716            .and_then(Value::as_bool)
1717            .unwrap_or(false);
1718        let Some(mut value) = raw.remove(field) else {
1719            if required {
1720                let code = feature_field_validation_code(rules, "missing_required_field");
1721                return Err(FeatureRuntimeError::ValidationReport(validation_report(
1722                    action_id,
1723                    vec![code.clone()],
1724                    Some((
1725                        field.clone(),
1726                        code,
1727                        "A required field was not provided.".to_owned(),
1728                    )),
1729                    None,
1730                )));
1731            }
1732            continue;
1733        };
1734        let field_ceiling = match rules.get("large_text") {
1735            None | Some(Value::Bool(false)) => 8 * 1024,
1736            Some(Value::Bool(true))
1737                if rules.get("type").and_then(Value::as_str) == Some("string") =>
1738            {
1739                32 * 1024
1740            }
1741            _ => {
1742                return Err(FeatureRuntimeError::InvalidContract(
1743                    "input.large_text".to_owned(),
1744                ));
1745            }
1746        };
1747        let max_length = rules
1748            .get("max_length")
1749            .and_then(Value::as_u64)
1750            .unwrap_or(256)
1751            .min(field_ceiling) as usize;
1752        let min_length = rules
1753            .get("min_length")
1754            .and_then(Value::as_u64)
1755            .unwrap_or(0)
1756            .min(max_length as u64) as usize;
1757        if value.len() > max_length
1758            || value.len() < min_length
1759            || (required && value.trim().is_empty())
1760        {
1761            let code = feature_field_validation_code(rules, "field_invalid");
1762            return Err(FeatureRuntimeError::ValidationReport(validation_report(
1763                action_id,
1764                vec![code.clone()],
1765                Some((
1766                    field.clone(),
1767                    code,
1768                    "The supplied value does not satisfy the declared bounds.".to_owned(),
1769                )),
1770                None,
1771            )));
1772        }
1773        if !declared_input_format_valid(rules.get("format").and_then(Value::as_str), &value)? {
1774            return Err(FeatureRuntimeError::InvalidInput(field.clone()));
1775        }
1776        if rules.get("format").and_then(Value::as_str) == Some("date_or_timestamp")
1777            && value.len() == 10
1778        {
1779            value.push_str("T00:00:00Z");
1780        }
1781        if matches!(
1782            rules.get("format").and_then(Value::as_str),
1783            Some("timestamp" | "date_or_timestamp")
1784        ) && !value.is_empty()
1785        {
1786            value = pharos_runtime_database::operations::canonical_stored_timestamp(&value)
1787                .map_err(|_| FeatureRuntimeError::InvalidInput(field.clone()))?;
1788        }
1789        if matches!(
1790            rules.get("format").and_then(Value::as_str),
1791            Some("https_url" | "web_url")
1792        ) && !value.is_empty()
1793        {
1794            value = super::outbound_url::web_url(&value)
1795                .ok_or_else(|| FeatureRuntimeError::InvalidInput(field.clone()))?
1796                .to_string();
1797        }
1798        let typed = match rules
1799            .get("type")
1800            .and_then(Value::as_str)
1801            .unwrap_or("string")
1802        {
1803            "string" => Value::String(value),
1804            "integer" => Value::from(
1805                value
1806                    .parse::<i64>()
1807                    .ok()
1808                    .filter(|number| {
1809                        rules
1810                            .get("minimum")
1811                            .and_then(Value::as_i64)
1812                            .is_none_or(|minimum| *number >= minimum)
1813                            && rules
1814                                .get("maximum")
1815                                .and_then(Value::as_i64)
1816                                .is_none_or(|maximum| *number <= maximum)
1817                    })
1818                    .ok_or_else(|| {
1819                        FeatureRuntimeError::ValidationReport(validation_report(
1820                            action_id,
1821                            vec!["field_invalid".to_owned()],
1822                            Some((
1823                                field.clone(),
1824                                "field_invalid".to_owned(),
1825                                "The supplied integer value is outside the declared bounds."
1826                                    .to_owned(),
1827                            )),
1828                            None,
1829                        ))
1830                    })?,
1831            ),
1832            "boolean" => {
1833                let normalized = value.trim().to_ascii_lowercase();
1834                match normalized.as_str() {
1835                    "true" | "1" | "yes" | "on" => Value::Bool(true),
1836                    "false" | "0" | "no" | "off" => Value::Bool(false),
1837                    _ => {
1838                        return Err(FeatureRuntimeError::ValidationReport(validation_report(
1839                            action_id,
1840                            vec!["field_invalid".to_owned()],
1841                            Some((
1842                                field.clone(),
1843                                "field_invalid".to_owned(),
1844                                "The supplied boolean value is outside the declared bounds."
1845                                    .to_owned(),
1846                            )),
1847                            None,
1848                        )));
1849                    }
1850                }
1851            }
1852            "enum" => {
1853                if !rules
1854                    .get("values")
1855                    .and_then(Value::as_array)
1856                    .is_some_and(|values| values.iter().any(|item| item.as_str() == Some(&value)))
1857                {
1858                    let code = feature_field_validation_code(rules, "field_invalid");
1859                    return Err(FeatureRuntimeError::ValidationReport(validation_report(
1860                        action_id,
1861                        vec![code.clone()],
1862                        Some((
1863                            field.clone(),
1864                            code,
1865                            "The supplied value is not one of the declared enum choices."
1866                                .to_owned(),
1867                        )),
1868                        None,
1869                    )));
1870                }
1871                Value::String(value)
1872            }
1873            _ => {
1874                return Err(FeatureRuntimeError::InvalidContract(format!(
1875                    "input.{field}.type"
1876                )));
1877            }
1878        };
1879        input.insert(field.clone(), typed);
1880    }
1881    for (field, rules) in declared {
1882        if let Some(earlier) = rules.get("not_before").and_then(Value::as_str) {
1883            if !matches!(
1884                rules.get("format").and_then(Value::as_str),
1885                Some("timestamp" | "date_or_timestamp")
1886            ) || !matches!(
1887                declared
1888                    .get(earlier)
1889                    .and_then(|rules| rules.get("format"))
1890                    .and_then(Value::as_str),
1891                Some("timestamp" | "date_or_timestamp")
1892            ) {
1893                return Err(FeatureRuntimeError::InvalidContract(
1894                    "input.not_before".into(),
1895                ));
1896            }
1897            if let (Some(end), Some(start)) = (
1898                input.get(field).and_then(Value::as_str),
1899                input.get(earlier).and_then(Value::as_str),
1900            ) && !end.is_empty()
1901                && !start.is_empty()
1902                && end < start
1903            {
1904                return Err(FeatureRuntimeError::InvalidInput(field.clone()));
1905            }
1906        }
1907    }
1908    if !raw.is_empty() {
1909        return Err(FeatureRuntimeError::ValidationReport(validation_report(
1910            action_id,
1911            vec!["undeclared_field".to_owned()],
1912            None,
1913            Some((
1914                "undeclared_field".to_owned(),
1915                "undeclared_field".to_owned(),
1916                "The request includes a field that is not part of the action contract.".to_owned(),
1917            )),
1918        )));
1919    }
1920    apply_input_normalization_hooks(input, hook_contract)
1921}
1922
1923/// Validates optional contact/link formats without changing existing string inputs.
1924fn declared_input_format_valid(
1925    format: Option<&str>,
1926    value: &str,
1927) -> Result<bool, FeatureRuntimeError> {
1928    match format {
1929        None => Ok(true),
1930        Some("email") => {
1931            Ok(value.is_empty() || super::json_commands::generated_surface_text_is_email(value))
1932        }
1933        Some("date") => Ok(value.is_empty()
1934            || value.len() == 10
1935                && pharos_runtime_database::operations::canonical_stored_timestamp(&format!(
1936                    "{value}T00:00:00Z"
1937                ))
1938                .is_ok()),
1939        Some("date_or_timestamp") => Ok(declared_input_format_valid(Some("date"), value)?
1940            || declared_input_format_valid(Some("timestamp"), value)?),
1941        Some("timestamp") => Ok(value.is_empty()
1942            || value
1943                .as_bytes()
1944                .get(10)
1945                .is_some_and(|ch| matches!(ch, b'T' | b't'))
1946                && (value.ends_with(['Z', 'z'])
1947                    || value
1948                        .get(19..)
1949                        .is_some_and(|tail| tail.contains(['+', '-'])))
1950                && pharos_runtime_database::operations::canonical_stored_timestamp(value).is_ok()),
1951        Some("utc_offset") => {
1952            let bytes = value.as_bytes();
1953            Ok(value.is_empty()
1954                || value == "UTC"
1955                || bytes.len() == 6
1956                    && matches!(bytes[0], b'+' | b'-')
1957                    && bytes[3] == b':'
1958                    && [1, 2, 4, 5].into_iter().all(|i| bytes[i].is_ascii_digit())
1959                    && value[1..3].parse::<u8>().is_ok_and(|hour| hour <= 23)
1960                    && value[4..6].parse::<u8>().is_ok_and(|minute| minute <= 59))
1961        }
1962        Some("https_url") => {
1963            Ok(value.is_empty() || super::outbound_url::https_url(value).is_some())
1964        }
1965        Some("web_url") => Ok(value.is_empty() || super::outbound_url::web_url(value).is_some()),
1966        Some(_) => Err(FeatureRuntimeError::InvalidContract(
1967            "input.format".to_owned(),
1968        )),
1969    }
1970}
1971
1972/// Converts one bound query result into the row-set shape used by aggregate guards.
1973#[cfg(not(target_os = "wasi"))]
1974/// Performs guard binding rows.
1975fn guard_binding_rows<'a>(
1976    query_id: &str,
1977    value: &'a Value,
1978) -> Result<Vec<&'a Map<String, Value>>, FeatureRuntimeError> {
1979    match value {
1980        Value::Array(rows) => rows
1981            .iter()
1982            .map(|row| {
1983                row.as_object()
1984                    .ok_or_else(|| FeatureRuntimeError::InvalidContract(query_id.to_owned()))
1985            })
1986            .collect(),
1987        Value::Object(row) => Ok(vec![row]),
1988        Value::Null => Ok(Vec::new()),
1989        _ => Err(FeatureRuntimeError::InvalidContract(query_id.to_owned())),
1990    }
1991}
1992
1993/// Evaluates generic numeric comparisons for one declared guard metric.
1994#[cfg(not(target_os = "wasi"))]
1995/// Performs guard bounds satisfied.
1996fn guard_bounds_satisfied(actual: i64, guard: &Value) -> bool {
1997    guard
1998        .get("minimum")
1999        .and_then(Value::as_i64)
2000        .is_none_or(|minimum| actual >= minimum)
2001        && guard
2002            .get("maximum")
2003            .and_then(Value::as_i64)
2004            .is_none_or(|maximum| actual <= maximum)
2005        && guard
2006            .get("equals")
2007            .and_then(Value::as_i64)
2008            .is_none_or(|expected| actual == expected)
2009}
2010
2011/// Rejects a transaction when one declared aggregate or existence guard fails.
2012#[cfg(not(target_os = "wasi"))]
2013/// Performs evaluate guard contract.
2014fn evaluate_guard_contract(
2015    action_id: &str,
2016    guard_contract: Option<&Value>,
2017    context: &FeatureRequestContext,
2018) -> Result<(), FeatureRuntimeError> {
2019    let Some(guards) = guard_contract
2020        .and_then(|contract| contract.get("guards"))
2021        .and_then(Value::as_array)
2022    else {
2023        return Ok(());
2024    };
2025    if guards.len() > 16 {
2026        return Err(FeatureRuntimeError::InvalidContract(format!(
2027            "{action_id}.guards"
2028        )));
2029    }
2030    for guard in guards {
2031        let guard_id = guard
2032            .get("guard_id")
2033            .and_then(Value::as_str)
2034            .unwrap_or("guard_failed");
2035        let kind = guard.get("kind").and_then(Value::as_str).ok_or_else(|| {
2036            FeatureRuntimeError::InvalidContract(format!("{action_id}.{guard_id}"))
2037        })?;
2038        let query_id = guard.get("query").and_then(Value::as_str).ok_or_else(|| {
2039            FeatureRuntimeError::InvalidContract(format!("{action_id}.{guard_id}"))
2040        })?;
2041        let binding = context
2042            .bindings
2043            .get(query_id)
2044            .ok_or_else(|| FeatureRuntimeError::MissingRecord(query_id.to_owned()))?;
2045        let rows = guard_binding_rows(query_id, binding)?;
2046        let add = guard
2047            .get("add")
2048            .map(|source| {
2049                resolve_value(source, context)?
2050                    .and_then(|value| value.as_i64())
2051                    .ok_or_else(|| {
2052                        FeatureRuntimeError::InvalidContract(format!("{action_id}.{guard_id}.add"))
2053                    })
2054            })
2055            .transpose()?
2056            .unwrap_or(0);
2057        let actual = match kind {
2058            "count" => i64::try_from(rows.len())
2059                .map_err(|_| {
2060                    FeatureRuntimeError::InvalidContract(format!("{action_id}.{guard_id}"))
2061                })?
2062                .checked_add(add)
2063                .ok_or_else(|| FeatureRuntimeError::Conflict(format!("{action_id}.{guard_id}")))?,
2064            "sum" => {
2065                let field = sql_identifier(
2066                    guard.get("field").and_then(Value::as_str).ok_or_else(|| {
2067                        FeatureRuntimeError::InvalidContract(format!(
2068                            "{action_id}.{guard_id}.field"
2069                        ))
2070                    })?,
2071                )?;
2072                rows.iter()
2073                    .try_fold(add, |total, row| {
2074                        row.get(&field)
2075                            .and_then(Value::as_i64)
2076                            .and_then(|value| total.checked_add(value))
2077                    })
2078                    .ok_or_else(|| {
2079                        FeatureRuntimeError::Conflict(format!("{action_id}.{guard_id}"))
2080                    })?
2081            }
2082            "exists" => i64::from(!rows.is_empty()),
2083            _ => {
2084                return Err(FeatureRuntimeError::InvalidContract(format!(
2085                    "{action_id}.{guard_id}"
2086                )));
2087            }
2088        };
2089        let satisfied = if kind == "exists"
2090            && guard.get("minimum").is_none()
2091            && guard.get("maximum").is_none()
2092            && guard.get("equals").is_none()
2093        {
2094            actual == 1
2095        } else {
2096            guard_bounds_satisfied(actual, guard)
2097        };
2098        if !satisfied {
2099            let message = guard
2100                .get("message")
2101                .and_then(Value::as_str)
2102                .unwrap_or("The action was rejected by a declared business-rule guard.");
2103            return Err(FeatureRuntimeError::ValidationReport(validation_report(
2104                action_id,
2105                vec![guard_id.to_owned()],
2106                None,
2107                Some((guard_id.to_owned(), guard_id.to_owned(), message.to_owned())),
2108            )));
2109        }
2110    }
2111    Ok(())
2112}
2113
2114/// Converts one query result into the binding value used by later dependent queries.
2115fn query_binding_value(
2116    query_id: &str,
2117    result_kind: &str,
2118    rows: &[Map<String, Value>],
2119) -> Result<Value, FeatureRuntimeError> {
2120    match result_kind {
2121        "many" => Ok(Value::Array(
2122            rows.iter().cloned().map(Value::Object).collect(),
2123        )),
2124        "optional" => Ok(rows
2125            .first()
2126            .cloned()
2127            .map(Value::Object)
2128            .unwrap_or(Value::Null)),
2129        _ => rows
2130            .first()
2131            .cloned()
2132            .map(Value::Object)
2133            .ok_or_else(|| FeatureRuntimeError::MissingRecord(query_id.to_owned())),
2134    }
2135}
2136
2137#[cfg(not(target_os = "wasi"))]
2138fn timestamp_before_seconds_utc(seconds_before_now: u64) -> String {
2139    let seconds = SystemTime::now()
2140        .duration_since(UNIX_EPOCH)
2141        .unwrap_or_default()
2142        .as_secs()
2143        .saturating_sub(seconds_before_now);
2144    super::chrono_like_timestamp(seconds)
2145}
2146
2147/// Flattens map or ordered-step transaction syntax into one deterministic operation list.
2148/// Performs transaction operations.
2149fn transaction_operations(
2150    transaction: &Value,
2151) -> Result<Vec<(String, Value)>, FeatureRuntimeError> {
2152    let object = transaction
2153        .as_object()
2154        .ok_or_else(|| FeatureRuntimeError::InvalidContract("transaction".to_owned()))?;
2155    if let Some(steps) = object.get("steps").and_then(Value::as_array) {
2156        if steps.is_empty() || steps.len() > 64 {
2157            return Err(FeatureRuntimeError::InvalidContract(
2158                "transaction.steps".to_owned(),
2159            ));
2160        }
2161        return steps
2162            .iter()
2163            .map(|step| {
2164                let operation = step.as_object().ok_or_else(|| {
2165                    FeatureRuntimeError::InvalidContract("transaction.step".to_owned())
2166                })?;
2167                if operation.len() != 1 {
2168                    return Err(FeatureRuntimeError::InvalidContract(
2169                        "transaction.step".to_owned(),
2170                    ));
2171                }
2172                let (kind, value) = operation.iter().next().expect("one operation");
2173                Ok((kind.clone(), value.clone()))
2174            })
2175            .collect();
2176    }
2177    Ok(object
2178        .iter()
2179        .filter(|(kind, _)| kind.as_str() != "idempotency")
2180        .map(|(kind, value)| (kind.clone(), value.clone()))
2181        .collect::<Vec<_>>())
2182}
2183
2184/// Executes a declared transaction through the configured operation interface.
2185fn execute_feature_transaction(
2186    app: &AppRuntime,
2187    feature: &Value,
2188    transaction: &Value,
2189    guard_contract: Option<&Value>,
2190    context: FeatureRequestContext,
2191    action_id: &str,
2192) -> Result<(), FeatureRuntimeError> {
2193    logical::execute_logical_transaction(
2194        app,
2195        feature,
2196        transaction,
2197        guard_contract,
2198        context,
2199        action_id,
2200    )
2201}
2202
2203/// Records one declared protected-asset authorization before streaming starts.
2204#[cfg(not(target_os = "wasi"))]
2205/// Records private asset authorization.
2206fn record_private_asset_authorization(
2207    app: &AppRuntime,
2208    declaration: &Value,
2209    actor_id: i64,
2210    protected_id: i64,
2211) -> Result<(), FeatureRuntimeError> {
2212    let Some(transaction) = declaration
2213        .get("on_authorized")
2214        .filter(|value| !value.is_null())
2215    else {
2216        return Ok(());
2217    };
2218    if actor_id <= 0 || protected_id <= 0 {
2219        return Err(FeatureRuntimeError::InvalidInput(
2220            "protected authorization".to_owned(),
2221        ));
2222    }
2223    let feature_id = declaration
2224        .get("feature_id")
2225        .and_then(Value::as_str)
2226        .ok_or_else(|| FeatureRuntimeError::InvalidContract("protected feature".to_owned()))?;
2227    let feature = load_feature(app, feature_id)?;
2228    let mut protected = Map::new();
2229    protected.insert("id".to_owned(), Value::from(protected_id));
2230    execute_feature_transaction(
2231        app,
2232        &feature,
2233        transaction,
2234        None,
2235        FeatureRequestContext {
2236            actor_id,
2237            protected,
2238            ..FeatureRequestContext::default()
2239        },
2240        "protected_asset_authorization",
2241    )
2242}
2243
2244/// Executes declared queries through the configured operation interface.
2245fn execute_feature_queries(
2246    app: &AppRuntime,
2247    feature: &Value,
2248    query_ids: &[String],
2249    context: &FeatureRequestContext,
2250) -> Result<BTreeMap<String, Vec<Map<String, Value>>>, FeatureRuntimeError> {
2251    logical::execute_logical_queries(app, feature, query_ids, context)
2252}
2253
2254/// Loads one compiled feature contract from the app artifact tree.
2255///
2256/// # Parameters
2257///
2258/// - `app`: Input consumed by [`crate::runtime::feature_runtime::load_feature`].
2259/// - `feature_id`: Input consumed by [`crate::runtime::feature_runtime::load_feature`].
2260///
2261/// # Returns
2262///
2263/// Returns the successful value produced by [`crate::runtime::feature_runtime::load_feature`] or an error when the operation fails.
2264///
2265/// # Errors
2266///
2267/// Returns an error value when [`crate::runtime::feature_runtime::load_feature`] cannot complete successfully.
2268///
2269/// # Performance
2270///
2271/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::runtime::feature_runtime::load_feature`].
2272///
2273/// # Examples
2274///
2275/// ```rust,ignore
2276/// let _result = pharos_native::runtime::feature_runtime::load_feature(todo!(), "feature");
2277/// ```
2278///
2279/// # Related
2280///
2281/// - [`crate::runtime::feature_runtime`] for adjacent helpers that share the same runtime surface.
2282pub fn load_feature(app: &AppRuntime, feature_id: &str) -> Result<Value, FeatureRuntimeError> {
2283    let root = artifact_root(app)?;
2284    let document = load_json(&root.join("data/contracts/features.json"))?;
2285    document
2286        .get("features")
2287        .and_then(Value::as_array)
2288        .and_then(|features| {
2289            features.iter().find(|feature| {
2290                feature.get("feature_id").and_then(Value::as_str) == Some(feature_id)
2291            })
2292        })
2293        .cloned()
2294        .ok_or_else(|| FeatureRuntimeError::InvalidContract(feature_id.to_owned()))
2295}
2296
2297/// Loads one compiled feature action contract by its stable identifier.
2298fn load_feature_action(app: &AppRuntime, action_id: &str) -> Result<Value, FeatureRuntimeError> {
2299    let root = artifact_root(app)?;
2300    let document = load_json(&root.join("data/contracts/actions.json"))?;
2301    document
2302        .get("actions")
2303        .and_then(Value::as_array)
2304        .and_then(|actions| {
2305            actions.iter().find(|action| {
2306                action.get("action_id").and_then(Value::as_str) == Some(action_id)
2307                    && action.get("feature_id").and_then(Value::as_str).is_some()
2308            })
2309        })
2310        .cloned()
2311        .ok_or_else(|| FeatureRuntimeError::InvalidContract(action_id.to_owned()))
2312}
2313
2314/// Builds the fallback redirect target for a feature action.
2315///
2316/// # Parameters
2317///
2318/// - `app`: Input consumed by [`crate::runtime::feature_runtime::action_fallback_redirect`].
2319/// - `action_id`: Input consumed by [`crate::runtime::feature_runtime::action_fallback_redirect`].
2320///
2321/// # Returns
2322///
2323/// Returns the successful value produced by [`crate::runtime::feature_runtime::action_fallback_redirect`] or an error when the operation fails.
2324///
2325/// # Errors
2326///
2327/// Returns an error value when [`crate::runtime::feature_runtime::action_fallback_redirect`] cannot complete successfully.
2328///
2329/// # Examples
2330///
2331/// ```rust,ignore
2332/// let _result = pharos_native::runtime::feature_runtime::action_fallback_redirect(todo!(), todo!());
2333/// ```
2334///
2335/// # Related
2336///
2337/// - [`crate::runtime::feature_runtime`] for adjacent helpers that share the same runtime surface.
2338pub fn action_fallback_redirect(
2339    app: &AppRuntime,
2340    action_id: &str,
2341) -> Result<String, FeatureRuntimeError> {
2342    load_feature_action(app, action_id)?
2343        .get("response_contract")
2344        .and_then(|value| value.get("fallback"))
2345        .and_then(|value| value.get("redirect"))
2346        .and_then(Value::as_str)
2347        .map(ToOwned::to_owned)
2348        .ok_or_else(|| FeatureRuntimeError::InvalidContract(action_id.to_owned()))
2349}
2350
2351/// Executes one declared feature action.
2352///
2353/// # Parameters
2354///
2355/// - `app`: Input consumed by [`crate::runtime::feature_runtime::execute_feature_action`].
2356/// - `route`: Input consumed by [`crate::runtime::feature_runtime::execute_feature_action`].
2357/// - `request`: Input consumed by [`crate::runtime::feature_runtime::execute_feature_action`].
2358/// - `actor_id`: Input consumed by [`crate::runtime::feature_runtime::execute_feature_action`].
2359///
2360/// # Returns
2361///
2362/// Returns the successful value produced by [`crate::runtime::feature_runtime::execute_feature_action`] or an error when the operation fails.
2363///
2364/// # Errors
2365///
2366/// Returns an error value when [`crate::runtime::feature_runtime::execute_feature_action`] cannot complete successfully.
2367///
2368/// # Performance
2369///
2370/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::runtime::feature_runtime::execute_feature_action`].
2371///
2372/// # Examples
2373///
2374/// ```rust,ignore
2375/// let _result = pharos_native::runtime::feature_runtime::execute_feature_action(todo!(), todo!(), todo!(), 1);
2376/// ```
2377///
2378/// # Related
2379///
2380/// - [`crate::runtime::feature_runtime`] for adjacent helpers that share the same runtime surface.
2381pub fn execute_feature_action(
2382    app: &AppRuntime,
2383    route: &Value,
2384    request: &super::http::HttpRequest,
2385    actor_id: i64,
2386) -> Result<FeatureActionOutcome, FeatureRuntimeError> {
2387    let feature_id = route
2388        .get("feature_id")
2389        .and_then(Value::as_str)
2390        .ok_or_else(|| FeatureRuntimeError::InvalidContract("route.feature_id".to_owned()))?;
2391    let action_id = route
2392        .get("action_id")
2393        .and_then(Value::as_str)
2394        .ok_or_else(|| FeatureRuntimeError::InvalidContract("route.action_id".to_owned()))?;
2395    let feature = load_feature(app, feature_id)?;
2396    let action = load_feature_action(app, action_id)?;
2397    let mut media = media_input::prepare(
2398        app,
2399        request,
2400        action
2401            .get("input_contract")
2402            .ok_or_else(|| FeatureRuntimeError::InvalidContract(action_id.to_owned()))?,
2403    )?;
2404    let input = validated_action_input(
2405        &media.request,
2406        action
2407            .get("input_contract")
2408            .ok_or_else(|| FeatureRuntimeError::InvalidContract(action_id.to_owned()))?,
2409        action.get("hook_contract"),
2410        action_id,
2411    )?;
2412    let request_path = request.target.split('?').next().unwrap_or("/");
2413    let context = FeatureRequestContext {
2414        request_target: request.target.clone(),
2415        actor_id,
2416        input,
2417        path_params: path_parameters(
2418            route.get("path").and_then(Value::as_str).unwrap_or("/"),
2419            request_path,
2420        )?,
2421        query_params: query_parameters(&request.target)?,
2422        ..FeatureRequestContext::default()
2423    };
2424    let transaction = selected_contract_variant(
2425        action
2426            .get("transaction_contract")
2427            .ok_or_else(|| FeatureRuntimeError::InvalidContract(action_id.to_owned()))?,
2428        &context,
2429    )?;
2430    let response = selected_contract_variant(
2431        action
2432            .get("response_contract")
2433            .ok_or_else(|| FeatureRuntimeError::InvalidContract(action_id.to_owned()))?,
2434        &context,
2435    )?;
2436    let redirect_only_path =
2437        if response.get("response_kind").and_then(Value::as_str) == Some("redirect") {
2438            let redirect = response
2439                .get("fallback")
2440                .and_then(|value| value.get("redirect"))
2441                .and_then(Value::as_str)
2442                .ok_or_else(|| FeatureRuntimeError::InvalidContract(action_id.to_owned()))?;
2443            Some(action_redirect_path(redirect, &context.path_params)?)
2444        } else {
2445            None
2446        };
2447    execute_feature_transaction(
2448        app,
2449        &feature,
2450        transaction,
2451        action.get("guard_contract"),
2452        context.clone(),
2453        action_id,
2454    )?;
2455    media.commit();
2456    if let Some(redirect) = redirect_only_path {
2457        return Ok(FeatureActionOutcome {
2458            status: 200,
2459            fragment_html: String::new(),
2460            redirect,
2461            redirect_only: true,
2462        });
2463    }
2464    let query_ids = response
2465        .get("queries")
2466        .and_then(Value::as_array)
2467        .into_iter()
2468        .flatten()
2469        .map(|value| {
2470            value
2471                .as_str()
2472                .map(ToOwned::to_owned)
2473                .ok_or_else(|| FeatureRuntimeError::InvalidContract(action_id.to_owned()))
2474        })
2475        .collect::<Result<Vec<_>, _>>()?;
2476    let params = render_query_params(app, &feature, &query_ids, &context)?;
2477    let template = response
2478        .get("template")
2479        .and_then(Value::as_str)
2480        .ok_or_else(|| FeatureRuntimeError::InvalidContract(action_id.to_owned()))?;
2481    let root = artifact_root(app)?;
2482    let template_path = super::features::safe_feature_template_path(&root, template)
2483        .ok_or_else(|| FeatureRuntimeError::InvalidContract(template.to_owned()))?;
2484    let fragment_html =
2485        super::json_commands::template_render_file(&template_path, &Value::Object(params))
2486            .ok_or_else(|| FeatureRuntimeError::RenderFailed(template.to_owned()))?;
2487    let redirect = response
2488        .get("fallback")
2489        .and_then(|value| value.get("redirect"))
2490        .and_then(Value::as_str)
2491        .ok_or_else(|| FeatureRuntimeError::InvalidContract(action_id.to_owned()))?
2492        .to_owned();
2493    Ok(FeatureActionOutcome {
2494        status: response
2495            .get("status")
2496            .and_then(Value::as_u64)
2497            .and_then(|status| u16::try_from(status).ok())
2498            .filter(|status| (200..=599).contains(status))
2499            .unwrap_or(200),
2500        fragment_html,
2501        redirect,
2502        redirect_only: false,
2503    })
2504}
2505
2506/// Appends a query and its declared query dependencies in executable order.
2507fn append_query_with_dependencies(
2508    queries: &Map<String, Value>,
2509    query_id: &str,
2510    output: &mut Vec<String>,
2511    visiting: &mut BTreeSet<String>,
2512) -> Result<(), FeatureRuntimeError> {
2513    if output.iter().any(|existing| existing == query_id) {
2514        return Ok(());
2515    }
2516    if !visiting.insert(query_id.to_owned()) {
2517        return Err(FeatureRuntimeError::InvalidContract(format!(
2518            "query dependency cycle: {query_id}"
2519        )));
2520    }
2521    let query = queries
2522        .get(query_id)
2523        .ok_or_else(|| FeatureRuntimeError::InvalidContract(query_id.to_owned()))?;
2524    let mut dependencies = BTreeSet::new();
2525    referenced_queries(query, &mut dependencies);
2526    for dependency in dependencies {
2527        if dependency != query_id {
2528            append_query_with_dependencies(queries, &dependency, output, visiting)?;
2529        }
2530    }
2531    visiting.remove(query_id);
2532    if !output.iter().any(|existing| existing == query_id) {
2533        output.push(query_id.to_owned());
2534    }
2535    Ok(())
2536}
2537
2538/// Executes one declared feature action and returns a generated API JSON item response.
2539fn feature_json_payload_for_query(
2540    app: &AppRuntime,
2541    feature: &Value,
2542    payload_query: &str,
2543    context: &FeatureRequestContext,
2544) -> Result<Value, FeatureRuntimeError> {
2545    let queries = feature
2546        .get("queries")
2547        .and_then(Value::as_object)
2548        .ok_or_else(|| FeatureRuntimeError::InvalidContract("feature.queries".to_owned()))?;
2549    let mut query_ids = Vec::new();
2550    append_query_with_dependencies(queries, payload_query, &mut query_ids, &mut BTreeSet::new())?;
2551    let query_results = execute_feature_queries(app, feature, &query_ids, context)?;
2552    Ok(query_results
2553        .get(payload_query)
2554        .and_then(|rows| rows.first())
2555        .cloned()
2556        .map(Value::Object)
2557        .unwrap_or(Value::Null))
2558}
2559
2560pub fn execute_feature_json_action(
2561    app: &AppRuntime,
2562    route_path: &str,
2563    request: &super::http::HttpRequest,
2564    actor_id: i64,
2565    action_id: &str,
2566) -> Result<(u16, Value), FeatureRuntimeError> {
2567    let action = load_feature_action(app, action_id)?;
2568    let feature_id = action
2569        .get("feature_id")
2570        .and_then(Value::as_str)
2571        .ok_or_else(|| FeatureRuntimeError::InvalidContract(format!("{action_id}.feature_id")))?;
2572    let feature = load_feature(app, feature_id)?;
2573    let mut media = media_input::prepare(
2574        app,
2575        request,
2576        action
2577            .get("input_contract")
2578            .ok_or_else(|| FeatureRuntimeError::InvalidContract(action_id.to_owned()))?,
2579    )?;
2580    let input = validated_action_input(
2581        &media.request,
2582        action
2583            .get("input_contract")
2584            .ok_or_else(|| FeatureRuntimeError::InvalidContract(action_id.to_owned()))?,
2585        action.get("hook_contract"),
2586        action_id,
2587    )?;
2588    let request_path = request.target.split('?').next().unwrap_or("/");
2589    let context = FeatureRequestContext {
2590        request_target: request.target.clone(),
2591        actor_id,
2592        input,
2593        path_params: path_parameters(route_path, request_path)?,
2594        query_params: query_parameters(&request.target)?,
2595        ..FeatureRequestContext::default()
2596    };
2597    let transaction = selected_contract_variant(
2598        action
2599            .get("transaction_contract")
2600            .ok_or_else(|| FeatureRuntimeError::InvalidContract(action_id.to_owned()))?,
2601        &context,
2602    )?;
2603    let response = selected_contract_variant(
2604        action
2605            .get("response_contract")
2606            .ok_or_else(|| FeatureRuntimeError::InvalidContract(action_id.to_owned()))?,
2607        &context,
2608    )?;
2609    let payload_before = response
2610        .get("payload_query_before")
2611        .and_then(Value::as_str)
2612        .map(|query| feature_json_payload_for_query(app, &feature, query, &context))
2613        .transpose()?;
2614    execute_feature_transaction(
2615        app,
2616        &feature,
2617        transaction,
2618        action.get("guard_contract"),
2619        context.clone(),
2620        action_id,
2621    )?;
2622    media.commit();
2623    let status = response
2624        .get("status")
2625        .and_then(Value::as_u64)
2626        .and_then(|status| u16::try_from(status).ok())
2627        .filter(|status| (200..=599).contains(status))
2628        .unwrap_or(200);
2629    let surface = response
2630        .get("surface")
2631        .and_then(Value::as_str)
2632        .ok_or_else(|| FeatureRuntimeError::InvalidContract(format!("{action_id}.surface")))?;
2633    let payload = if let Some(payload) = payload_before {
2634        payload
2635    } else {
2636        let payload_query = response
2637            .get("payload_query")
2638            .and_then(Value::as_str)
2639            .ok_or_else(|| {
2640                FeatureRuntimeError::InvalidContract(format!("{action_id}.payload_query"))
2641            })?;
2642        feature_json_payload_for_query(app, &feature, payload_query, &context)?
2643    };
2644    Ok((
2645        status,
2646        serde_json::json!({
2647            "schema": "pharos.generated.api.item.v1",
2648            "surface": surface,
2649            "payload": payload
2650        }),
2651    ))
2652}
2653
2654/// Builds the query-parameter map available to a feature request.
2655///
2656/// # Parameters
2657///
2658/// - `target`: Input consumed by [`crate::runtime::feature_runtime::query_parameters`].
2659///
2660/// # Returns
2661///
2662/// Returns the successful value produced by [`crate::runtime::feature_runtime::query_parameters`] or an error when the operation fails.
2663///
2664/// # Errors
2665///
2666/// Returns an error value when [`crate::runtime::feature_runtime::query_parameters`] cannot complete successfully.
2667///
2668/// # Performance
2669///
2670/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::runtime::feature_runtime::query_parameters`].
2671///
2672/// # Examples
2673///
2674/// ```rust,ignore
2675/// let _result = pharos_native::runtime::feature_runtime::query_parameters("/");
2676/// ```
2677///
2678/// # Related
2679///
2680/// - [`crate::runtime::feature_runtime`] for adjacent helpers that share the same runtime surface.
2681pub fn query_parameters(target: &str) -> Result<Map<String, Value>, FeatureRuntimeError> {
2682    let query = target.split_once('?').map(|(_, query)| query).unwrap_or("");
2683    if query.len() > 8 * 1024 {
2684        return Err(FeatureRuntimeError::InvalidInput("query".to_owned()));
2685    }
2686    let mut values = Map::new();
2687    for pair in query.split('&').filter(|pair| !pair.is_empty()) {
2688        let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
2689        let key = super::json_commands::url_decode(key)
2690            .ok_or_else(|| FeatureRuntimeError::InvalidInput("query".to_owned()))?;
2691        let value = super::json_commands::url_decode(value)
2692            .ok_or_else(|| FeatureRuntimeError::InvalidInput(key.clone()))?;
2693        sql_identifier(&key)?;
2694        if values.len() >= MAX_QUERY_FILTERS
2695            || values.insert(key.clone(), Value::String(value)).is_some()
2696        {
2697            return Err(FeatureRuntimeError::InvalidInput(key));
2698        }
2699    }
2700    Ok(values)
2701}
2702
2703/// Builds the path-parameter map available to a feature request.
2704///
2705/// # Parameters
2706///
2707/// - `pattern`: Input consumed by [`crate::runtime::feature_runtime::path_parameters`].
2708/// - `path`: Input consumed by [`crate::runtime::feature_runtime::path_parameters`].
2709///
2710/// # Returns
2711///
2712/// Returns the successful value produced by [`crate::runtime::feature_runtime::path_parameters`] or an error when the operation fails.
2713///
2714/// # Errors
2715///
2716/// Returns an error value when [`crate::runtime::feature_runtime::path_parameters`] cannot complete successfully.
2717///
2718/// # Performance
2719///
2720/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::runtime::feature_runtime::path_parameters`].
2721///
2722/// # Examples
2723///
2724/// ```rust,ignore
2725/// let _result = pharos_native::runtime::feature_runtime::path_parameters(todo!(), std::path::Path::new("fixture.json"));
2726/// ```
2727///
2728/// # Related
2729///
2730/// - [`crate::runtime::feature_runtime`] for adjacent helpers that share the same runtime surface.
2731fn path_parameter_value(parameter: &str, decoded: &str) -> Value {
2732    if (parameter == "id" || parameter.ends_with("_id"))
2733        && decoded.len() <= 18
2734        && decoded.chars().all(|character| character.is_ascii_digit())
2735        && let Ok(value) = decoded.parse::<i64>()
2736    {
2737        return Value::from(value);
2738    }
2739    Value::String(decoded.to_owned())
2740}
2741
2742pub fn path_parameters(
2743    pattern: &str,
2744    path: &str,
2745) -> Result<Map<String, Value>, FeatureRuntimeError> {
2746    let pattern_parts = pattern.trim_matches('/').split('/').collect::<Vec<_>>();
2747    let path_parts = path.trim_matches('/').split('/').collect::<Vec<_>>();
2748    if pattern_parts.len() != path_parts.len() {
2749        return Err(FeatureRuntimeError::InvalidInput("path".to_owned()));
2750    }
2751    let mut values = Map::new();
2752    for (declared, actual) in pattern_parts.into_iter().zip(path_parts) {
2753        let parameter = declared.strip_prefix(':').or_else(|| {
2754            declared
2755                .strip_prefix('{')
2756                .and_then(|value| value.strip_suffix('}'))
2757        });
2758        if let Some(parameter) = parameter {
2759            sql_identifier(parameter)?;
2760            let decoded = super::json_commands::url_decode(actual)
2761                .ok_or_else(|| FeatureRuntimeError::InvalidInput(parameter.to_owned()))?;
2762            if decoded.is_empty() || decoded.len() > 256 || decoded.contains('/') {
2763                return Err(FeatureRuntimeError::InvalidInput(parameter.to_owned()));
2764            }
2765            values.insert(
2766                parameter.to_owned(),
2767                path_parameter_value(parameter, &decoded),
2768            );
2769        } else if declared != actual {
2770            return Err(FeatureRuntimeError::InvalidInput("path".to_owned()));
2771        }
2772    }
2773    Ok(values)
2774}
2775
2776fn missing_template_keys(template_path: &Path, params: &Map<String, Value>) -> Vec<String> {
2777    let Ok(template_text) = fs::read_to_string(template_path) else {
2778        return vec!["<template unreadable>".to_owned()];
2779    };
2780    let mut missing = Vec::new();
2781    let mut cursor = 0usize;
2782    while let Some(offset) = template_text[cursor..].find('{') {
2783        let open = cursor + offset;
2784        let Some(close_offset) = template_text[open + 1..].find('}') else {
2785            missing.push("<unclosed placeholder>".to_owned());
2786            break;
2787        };
2788        let close = open + 1 + close_offset;
2789        let raw = &template_text[open + 1..close];
2790        let key = raw.split_once(':').map_or(raw, |(_, key)| key);
2791        if !params.contains_key(key) {
2792            missing.push(key.to_owned());
2793        }
2794        cursor = close + 1;
2795    }
2796    missing.sort();
2797    missing.dedup();
2798    missing
2799}
2800
2801/// Renders selected query rows through app-owned row templates and scalar outputs.
2802fn query_render_params(
2803    artifact_root: &Path,
2804    feature: &Value,
2805    results: &BTreeMap<String, Vec<Map<String, Value>>>,
2806    base_params: &Map<String, Value>,
2807) -> Result<Map<String, Value>, FeatureRuntimeError> {
2808    // HTML form controls use numeric flags. Keep that presentation conversion
2809    // separate from typed query bindings and transactional read guards.
2810    let mut rendered_results = results.clone();
2811    for rows in rendered_results.values_mut() {
2812        for row in rows {
2813            for value in row.values_mut() {
2814                if let Some(flag) = value.as_bool() {
2815                    *value = Value::from(i64::from(flag));
2816                }
2817            }
2818        }
2819    }
2820    let results = &rendered_results;
2821    let queries = feature
2822        .get("queries")
2823        .and_then(Value::as_object)
2824        .ok_or_else(|| FeatureRuntimeError::InvalidContract("feature.queries".to_owned()))?;
2825    let mut params = base_params.clone();
2826    for (query_id, rows) in results {
2827        let query = queries
2828            .get(query_id)
2829            .ok_or_else(|| FeatureRuntimeError::InvalidContract(query_id.clone()))?;
2830        params.insert(format!("{query_id}_count"), Value::from(rows.len()));
2831        if let Some(aggregates) = query.get("aggregates").and_then(Value::as_object) {
2832            if aggregates.len() > 16 {
2833                return Err(FeatureRuntimeError::InvalidContract(format!(
2834                    "{query_id}.aggregates"
2835                )));
2836            }
2837            for (output, aggregate) in aggregates {
2838                sql_identifier(output)?;
2839                let operation = aggregate
2840                    .get("operation")
2841                    .and_then(Value::as_str)
2842                    .ok_or_else(|| {
2843                        FeatureRuntimeError::InvalidContract(format!(
2844                            "{query_id}.aggregates.{output}"
2845                        ))
2846                    })?;
2847                let value = match operation {
2848                    "count" => i64::try_from(rows.len()).map_err(|_| {
2849                        FeatureRuntimeError::InvalidContract(format!(
2850                            "{query_id}.aggregates.{output}"
2851                        ))
2852                    })?,
2853                    "sum" => {
2854                        let field = sql_identifier(
2855                            aggregate
2856                                .get("field")
2857                                .and_then(Value::as_str)
2858                                .unwrap_or_default(),
2859                        )?;
2860                        rows.iter()
2861                            .try_fold(0i64, |total, row| {
2862                                row.get(&field)
2863                                    .and_then(Value::as_i64)
2864                                    .and_then(|value| total.checked_add(value))
2865                            })
2866                            .ok_or_else(|| {
2867                                FeatureRuntimeError::Conflict(format!(
2868                                    "{query_id}.aggregates.{output}"
2869                                ))
2870                            })?
2871                    }
2872                    _ => {
2873                        return Err(FeatureRuntimeError::InvalidContract(format!(
2874                            "{query_id}.aggregates.{output}"
2875                        )));
2876                    }
2877                };
2878                params.insert(output.clone(), Value::from(value));
2879            }
2880        }
2881        if let Some(outputs) = query.get("outputs").and_then(Value::as_object) {
2882            let first = rows.first();
2883            for (output, field) in outputs {
2884                sql_identifier(output)?;
2885                let field = field.as_str().ok_or_else(|| {
2886                    FeatureRuntimeError::InvalidContract(format!("{query_id}.outputs"))
2887                })?;
2888                params.insert(
2889                    output.clone(),
2890                    first
2891                        .and_then(|row| row.get(field))
2892                        .cloned()
2893                        .unwrap_or(Value::Null),
2894                );
2895            }
2896        }
2897    }
2898    // Resolve every scalar output before rendering dependent rows; map ordering cannot hide cross-query bindings.
2899    let scalar_params = params.clone();
2900    for (query_id, rows) in results {
2901        let query = queries
2902            .get(query_id)
2903            .ok_or_else(|| FeatureRuntimeError::InvalidContract(query_id.clone()))?;
2904        let Some(render) = query.get("render") else {
2905            continue;
2906        };
2907        let template = render
2908            .get("template")
2909            .and_then(Value::as_str)
2910            .ok_or_else(|| FeatureRuntimeError::InvalidContract(format!("{query_id}.render")))?;
2911        let output = sql_identifier(render.get("output").and_then(Value::as_str).ok_or_else(
2912            || FeatureRuntimeError::InvalidContract(format!("{query_id}.render.output")),
2913        )?)?;
2914        let template_path = super::features::safe_feature_template_path(artifact_root, template)
2915            .ok_or_else(|| FeatureRuntimeError::InvalidContract(template.to_owned()))?;
2916        let mut html = String::new();
2917        for row in rows {
2918            let mut row_params = scalar_params.clone();
2919            row_params.extend(row.clone());
2920            html.push_str(
2921                &super::json_commands::template_render_file(
2922                    &template_path,
2923                    &Value::Object(row_params.clone()),
2924                )
2925                .ok_or_else(|| {
2926                    eprintln!(
2927                        "feature row template render failed: template={} missing_keys={:?}",
2928                        template,
2929                        missing_template_keys(&template_path, &row_params)
2930                    );
2931                    FeatureRuntimeError::RenderFailed(template.to_owned())
2932                })?,
2933            );
2934        }
2935        if rows.is_empty()
2936            && let Some(empty_template) = render.get("empty_template").and_then(Value::as_str)
2937        {
2938            let empty_path =
2939                super::features::safe_feature_template_path(artifact_root, empty_template)
2940                    .ok_or_else(|| {
2941                        FeatureRuntimeError::InvalidContract(empty_template.to_owned())
2942                    })?;
2943            html = super::json_commands::template_render_file(
2944                &empty_path,
2945                &Value::Object(scalar_params.clone()),
2946            )
2947            .ok_or_else(|| {
2948                eprintln!(
2949                    "feature empty template render failed: template={} missing_keys={:?}",
2950                    empty_template,
2951                    missing_template_keys(&empty_path, &scalar_params)
2952                );
2953                FeatureRuntimeError::RenderFailed(empty_template.to_owned())
2954            })?;
2955        }
2956        params.insert(output, Value::String(html));
2957    }
2958    Ok(params)
2959}
2960
2961/// Builds template parameters for a feature query result.
2962///
2963/// # Parameters
2964///
2965/// - `app`: Input consumed by [`crate::runtime::feature_runtime::render_query_params`].
2966/// - `feature`: Input consumed by [`crate::runtime::feature_runtime::render_query_params`].
2967/// - `query_ids`: Input consumed by [`crate::runtime::feature_runtime::render_query_params`].
2968/// - `context`: Input consumed by [`crate::runtime::feature_runtime::render_query_params`].
2969///
2970/// # Returns
2971///
2972/// Returns the successful value produced by [`crate::runtime::feature_runtime::render_query_params`] or an error when the operation fails.
2973///
2974/// # Errors
2975///
2976/// Returns an error value when [`crate::runtime::feature_runtime::render_query_params`] cannot complete successfully.
2977///
2978/// # Examples
2979///
2980/// ```rust,ignore
2981/// let _result = pharos_native::runtime::feature_runtime::render_query_params(todo!(), todo!(), todo!(), todo!());
2982/// ```
2983///
2984/// # Related
2985///
2986/// - [`crate::runtime::feature_runtime`] for adjacent helpers that share the same runtime surface.
2987pub fn execute_feature_query_rows_json(
2988    app: &AppRuntime,
2989    feature_id: &str,
2990    query_ids: &[String],
2991    context: &FeatureRequestContext,
2992) -> Result<Value, FeatureRuntimeError> {
2993    if query_ids.len() > MAX_QUERY_FILTERS {
2994        return Err(FeatureRuntimeError::InvalidContract(
2995            "query count".to_owned(),
2996        ));
2997    }
2998    let feature = load_feature(app, feature_id)?;
2999    let mut results = None;
3000    for attempt in 0..40 {
3001        match execute_feature_queries(app, &feature, query_ids, context) {
3002            Ok(query_results) => {
3003                results = Some(query_results);
3004                break;
3005            }
3006            Err(FeatureRuntimeError::DatabaseUnavailable) if attempt < 39 => {
3007                std::thread::sleep(std::time::Duration::from_millis(50));
3008            }
3009            Err(error) => return Err(error),
3010        }
3011    }
3012    let results = results.ok_or(FeatureRuntimeError::DatabaseUnavailable)?;
3013    let mut root = Map::new();
3014    root.insert(
3015        "schema".to_owned(),
3016        Value::String("pharos.feature-query-result.v1".to_owned()),
3017    );
3018    root.insert(
3019        "feature_id".to_owned(),
3020        Value::String(feature_id.to_owned()),
3021    );
3022    let mut query_root = Map::new();
3023    for (query_id, rows) in results {
3024        query_root.insert(
3025            query_id,
3026            Value::Array(rows.into_iter().map(Value::Object).collect()),
3027        );
3028    }
3029    root.insert("queries".to_owned(), Value::Object(query_root));
3030    Ok(Value::Object(root))
3031}
3032
3033pub fn render_query_params(
3034    app: &AppRuntime,
3035    feature: &Value,
3036    query_ids: &[String],
3037    context: &FeatureRequestContext,
3038) -> Result<Map<String, Value>, FeatureRuntimeError> {
3039    if query_ids.len() > MAX_QUERY_FILTERS {
3040        return Err(FeatureRuntimeError::InvalidContract(
3041            "query count".to_owned(),
3042        ));
3043    }
3044    let mut results = None;
3045    for attempt in 0..40 {
3046        match execute_feature_queries(app, feature, query_ids, context) {
3047            Ok(query_results) => {
3048                results = Some(query_results);
3049                break;
3050            }
3051            Err(FeatureRuntimeError::DatabaseUnavailable) if attempt < 39 => {
3052                std::thread::sleep(std::time::Duration::from_millis(50));
3053            }
3054            Err(error) => return Err(error),
3055        }
3056    }
3057    let results = results.ok_or(FeatureRuntimeError::DatabaseUnavailable)?;
3058    let base_params = manifest_declared_template_params(app)?;
3059    query_render_params(&artifact_root(app)?, feature, &results, &base_params)
3060}
3061
3062/// Renders a feature page body with an attached error state.
3063///
3064/// # Parameters
3065///
3066/// - `app`: Input consumed by [`crate::runtime::feature_runtime::render_feature_page_body_with_error`].
3067/// - `route`: Input consumed by [`crate::runtime::feature_runtime::render_feature_page_body_with_error`].
3068/// - `target`: Input consumed by [`crate::runtime::feature_runtime::render_feature_page_body_with_error`].
3069/// - `actor_id`: Input consumed by [`crate::runtime::feature_runtime::render_feature_page_body_with_error`].
3070/// - `csrf_token`: Input consumed by [`crate::runtime::feature_runtime::render_feature_page_body_with_error`].
3071/// - `error_message`: Input consumed by [`crate::runtime::feature_runtime::render_feature_page_body_with_error`].
3072///
3073/// # Returns
3074///
3075/// Returns the successful value produced by [`crate::runtime::feature_runtime::render_feature_page_body_with_error`] or an error when the operation fails.
3076///
3077/// # Errors
3078///
3079/// Returns an error value when [`crate::runtime::feature_runtime::render_feature_page_body_with_error`] cannot complete successfully.
3080///
3081/// # Performance
3082///
3083/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::runtime::feature_runtime::render_feature_page_body_with_error`].
3084///
3085/// # Examples
3086///
3087/// ```rust,ignore
3088/// let _result = pharos_native::runtime::feature_runtime::render_feature_page_body_with_error(todo!(), todo!(), "/", 1, todo!(), todo!());
3089/// ```
3090///
3091/// # Related
3092///
3093/// - [`crate::runtime::feature_runtime`] for adjacent helpers that share the same runtime surface.
3094pub fn render_feature_page_body_with_error(
3095    app: &AppRuntime,
3096    route: &Value,
3097    target: &str,
3098    actor_id: i64,
3099    csrf_token: &str,
3100    error_message: Option<&str>,
3101) -> Result<String, FeatureRuntimeError> {
3102    render_feature_page_body_with_error_and_params(
3103        app,
3104        route,
3105        target,
3106        actor_id,
3107        csrf_token,
3108        error_message,
3109        Map::new(),
3110    )
3111}
3112
3113/// Renders a feature page body with additional caller-supplied template params.
3114pub fn render_feature_page_body_with_error_and_params(
3115    app: &AppRuntime,
3116    route: &Value,
3117    target: &str,
3118    actor_id: i64,
3119    csrf_token: &str,
3120    error_message: Option<&str>,
3121    extra_params: Map<String, Value>,
3122) -> Result<String, FeatureRuntimeError> {
3123    let feature_id = route
3124        .get("feature_id")
3125        .and_then(Value::as_str)
3126        .ok_or_else(|| FeatureRuntimeError::InvalidContract("route.feature_id".to_owned()))?;
3127    let page_id = route
3128        .get("page_id")
3129        .and_then(Value::as_str)
3130        .ok_or_else(|| FeatureRuntimeError::InvalidContract("route.page_id".to_owned()))?;
3131    let feature = load_feature(app, feature_id)?;
3132    let query_ids = route
3133        .get("queries")
3134        .and_then(Value::as_array)
3135        .into_iter()
3136        .flatten()
3137        .map(|value| {
3138            value
3139                .as_str()
3140                .map(ToOwned::to_owned)
3141                .ok_or_else(|| FeatureRuntimeError::InvalidContract("route.queries".to_owned()))
3142        })
3143        .collect::<Result<Vec<_>, _>>()?;
3144    let request_path = target.split('?').next().unwrap_or("/");
3145    let context = FeatureRequestContext {
3146        actor_id,
3147        path_params: path_parameters(
3148            route.get("path").and_then(Value::as_str).unwrap_or("/"),
3149            request_path,
3150        )?,
3151        request_target: target.to_owned(),
3152        query_params: query_parameters(target)?,
3153        ..FeatureRequestContext::default()
3154    };
3155    let root = artifact_root(app)?;
3156    let mut params = render_query_params(app, &feature, &query_ids, &context)?;
3157    params.insert(
3158        "pharos_idempotency_key".to_owned(),
3159        Value::String(
3160            super::dynamic::random_secret_hex(24)
3161                .map_err(|_| FeatureRuntimeError::RenderFailed(page_id.to_owned()))?,
3162        ),
3163    );
3164    if page_id == "login" {
3165        let next_path = context
3166            .query_params
3167            .get("next")
3168            .and_then(Value::as_str)
3169            .unwrap_or_default();
3170        let auth_params =
3171            super::json_commands::template_login_page_json(&root, error_message, Some(next_path))
3172                .and_then(|json| serde_json::from_str::<Value>(&json).ok())
3173                .and_then(|value| value.as_object().cloned())
3174                .ok_or_else(|| FeatureRuntimeError::RenderFailed(page_id.to_owned()))?;
3175        for (key, value) in auth_params {
3176            params.insert(key, value);
3177        }
3178    }
3179    if let Some(state_root) = payment_provider_render_state_root(app, request_path)? {
3180        for (key, value) in super::payment_provider::payment_provider_render_params(
3181            &state_root,
3182            &root,
3183            request_path,
3184        ) {
3185            params.insert(key, value);
3186        }
3187    }
3188    for (key, value) in extra_params {
3189        params.insert(key, value);
3190    }
3191    let page_relative = super::json_commands::declared_page_relative_path(&root, page_id)
3192        .ok_or_else(|| FeatureRuntimeError::InvalidContract(page_id.to_owned()))?;
3193    let page_spec = load_json(&root.join(page_relative))?;
3194    let template = page_spec
3195        .get("template_file")
3196        .and_then(Value::as_str)
3197        .ok_or_else(|| FeatureRuntimeError::InvalidContract(page_id.to_owned()))?;
3198    let template_path = super::features::safe_feature_template_path(&root, template)
3199        .ok_or_else(|| FeatureRuntimeError::InvalidContract(template.to_owned()))?;
3200    let body =
3201        super::json_commands::template_render_file(&template_path, &Value::Object(params.clone()))
3202            .ok_or_else(|| {
3203                eprintln!(
3204                    "feature page template render failed: page={} template={} missing_keys={:?}",
3205                    page_id,
3206                    template,
3207                    missing_template_keys(&template_path, &params)
3208                );
3209                FeatureRuntimeError::RenderFailed(template.to_owned())
3210            })?;
3211    let body = super::json_commands::template_apply_base_path(&body, app.base_path.as_deref())
3212        .ok_or_else(|| FeatureRuntimeError::RenderFailed(page_id.to_owned()))?;
3213    Ok(super::dynamic::csrf_inject_hidden_inputs(
3214        &root, &body, csrf_token,
3215    ))
3216}
3217
3218/// Renders a feature page body.
3219///
3220/// # Parameters
3221///
3222/// - `app`: Input consumed by [`crate::runtime::feature_runtime::render_feature_page_body`].
3223/// - `route`: Input consumed by [`crate::runtime::feature_runtime::render_feature_page_body`].
3224/// - `target`: Input consumed by [`crate::runtime::feature_runtime::render_feature_page_body`].
3225/// - `actor_id`: Input consumed by [`crate::runtime::feature_runtime::render_feature_page_body`].
3226/// - `csrf_token`: Input consumed by [`crate::runtime::feature_runtime::render_feature_page_body`].
3227///
3228/// # Returns
3229///
3230/// Returns the successful value produced by [`crate::runtime::feature_runtime::render_feature_page_body`] or an error when the operation fails.
3231///
3232/// # Errors
3233///
3234/// Returns an error value when [`crate::runtime::feature_runtime::render_feature_page_body`] cannot complete successfully.
3235///
3236/// # Examples
3237///
3238/// ```rust,ignore
3239/// let _result = pharos_native::runtime::feature_runtime::render_feature_page_body(todo!(), todo!(), "/", 1, todo!());
3240/// ```
3241///
3242/// # Related
3243///
3244/// - [`crate::runtime::feature_runtime`] for adjacent helpers that share the same runtime surface.
3245pub fn render_feature_page_body(
3246    app: &AppRuntime,
3247    route: &Value,
3248    target: &str,
3249    actor_id: i64,
3250    csrf_token: &str,
3251) -> Result<String, FeatureRuntimeError> {
3252    render_feature_page_body_with_error(app, route, target, actor_id, csrf_token, None)
3253}
3254
3255fn role_fragment_key(role: &str) -> Option<String> {
3256    if role.is_empty()
3257        || role.len() > 64
3258        || !role
3259            .chars()
3260            .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
3261    {
3262        return None;
3263    }
3264    Some(format!("{role}_nav_html"))
3265}
3266
3267fn role_nav_fragment_html(root: &Path, role: &str) -> String {
3268    let Some(key) = role_fragment_key(role) else {
3269        return String::new();
3270    };
3271    let Some(role_stem) = key.strip_suffix("_html") else {
3272        return String::new();
3273    };
3274    let fragment_path = root
3275        .join("templates/layout/fragments")
3276        .join(format!("{role_stem}.html"));
3277    if !fragment_path.is_file() {
3278        return String::new();
3279    }
3280    super::json_commands::template_render_file(&fragment_path, &serde_json::json!({}))
3281        .unwrap_or_default()
3282}
3283
3284fn layout_role_nav_params(layout_path: &Path) -> serde_json::Map<String, Value> {
3285    let mut params = serde_json::Map::new();
3286    let Ok(layout) = fs::read_to_string(layout_path) else {
3287        return params;
3288    };
3289    for segment in layout.split("{html:").skip(1) {
3290        let Some((key, _)) = segment.split_once('}') else {
3291            continue;
3292        };
3293        if key.ends_with("_nav_html")
3294            && key
3295                .chars()
3296                .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
3297        {
3298            params.insert(key.to_owned(), Value::String(String::new()));
3299        }
3300    }
3301    params
3302}
3303
3304fn insert_actor_role_nav_params(
3305    app: &AppRuntime,
3306    root: &Path,
3307    actor_id: i64,
3308    params: &mut serde_json::Map<String, Value>,
3309) -> Result<(), FeatureRuntimeError> {
3310    if actor_id <= 0 {
3311        return Ok(());
3312    }
3313    if params.contains_key("authenticated_user_nav_html") {
3314        params.insert(
3315            "authenticated_user_nav_html".to_owned(),
3316            Value::String(role_nav_fragment_html(root, "authenticated_user")),
3317        );
3318    }
3319    for role in actor_roles(app, actor_id).map_err(logical::map_database_error)? {
3320        if let Some(key) = role_fragment_key(&role)
3321            && params.contains_key(&key)
3322        {
3323            params.insert(key, Value::String(role_nav_fragment_html(root, &role)));
3324        }
3325    }
3326    Ok(())
3327}
3328
3329/// Renders a full feature page with an attached error state.
3330///
3331/// # Parameters
3332///
3333/// - `app`: Input consumed by [`crate::runtime::feature_runtime::render_feature_page_html_with_error`].
3334/// - `route`: Input consumed by [`crate::runtime::feature_runtime::render_feature_page_html_with_error`].
3335/// - `target`: Input consumed by [`crate::runtime::feature_runtime::render_feature_page_html_with_error`].
3336/// - `actor_id`: Input consumed by [`crate::runtime::feature_runtime::render_feature_page_html_with_error`].
3337/// - `has_user`: Input consumed by [`crate::runtime::feature_runtime::render_feature_page_html_with_error`].
3338/// - `csrf_token`: Input consumed by [`crate::runtime::feature_runtime::render_feature_page_html_with_error`].
3339/// - `error_message`: Input consumed by [`crate::runtime::feature_runtime::render_feature_page_html_with_error`].
3340///
3341/// # Returns
3342///
3343/// Returns the successful value produced by [`crate::runtime::feature_runtime::render_feature_page_html_with_error`] or an error when the operation fails.
3344///
3345/// # Errors
3346///
3347/// Returns an error value when [`crate::runtime::feature_runtime::render_feature_page_html_with_error`] cannot complete successfully.
3348///
3349/// # Examples
3350///
3351/// ```rust,ignore
3352/// let _result = pharos_native::runtime::feature_runtime::render_feature_page_html_with_error(todo!(), todo!(), "/", 1, todo!(), todo!(), todo!());
3353/// ```
3354///
3355/// # Related
3356///
3357/// - [`crate::runtime::feature_runtime`] for adjacent helpers that share the same runtime surface.
3358pub fn render_feature_page_html_with_error(
3359    app: &AppRuntime,
3360    route: &Value,
3361    target: &str,
3362    actor_id: i64,
3363    has_user: bool,
3364    csrf_token: &str,
3365    error_message: Option<&str>,
3366) -> Result<String, FeatureRuntimeError> {
3367    let feature_id = route
3368        .get("feature_id")
3369        .and_then(Value::as_str)
3370        .ok_or_else(|| FeatureRuntimeError::InvalidContract("route.feature_id".to_owned()))?;
3371    let page_id = route
3372        .get("page_id")
3373        .and_then(Value::as_str)
3374        .ok_or_else(|| FeatureRuntimeError::InvalidContract("route.page_id".to_owned()))?;
3375    let body = render_feature_page_body_with_error(
3376        app,
3377        route,
3378        target,
3379        actor_id,
3380        csrf_token,
3381        error_message,
3382    )?;
3383    let root = artifact_root(app)?;
3384    let page_relative = super::json_commands::declared_page_relative_path(&root, page_id)
3385        .ok_or_else(|| FeatureRuntimeError::InvalidContract(page_id.to_owned()))?;
3386    let page_spec = load_json(&root.join(page_relative))?;
3387    let title = page_spec
3388        .get("title")
3389        .and_then(Value::as_str)
3390        .unwrap_or(page_id);
3391    let layout_id = route
3392        .get("layout_id")
3393        .and_then(Value::as_str)
3394        .or_else(|| page_spec.get("layout_id").and_then(Value::as_str));
3395    if layout_id == Some("architect_shell") {
3396        let html = super::json_commands::template_layout_page_html(
3397            &root,
3398            app.base_path.as_deref(),
3399            layout_id,
3400            Some("en"),
3401            title,
3402            &body,
3403            Some(target),
3404            has_user,
3405            if super::actor_has_route_policy(app, actor_id, "manage_llm_vendors")
3406                .map_err(logical::map_database_error)?
3407            {
3408                1
3409            } else {
3410                0
3411            },
3412        )
3413        .ok_or_else(|| FeatureRuntimeError::RenderFailed(page_id.to_owned()))?;
3414        return Ok(super::dynamic::csrf_inject_hidden_inputs(
3415            &root, &html, csrf_token,
3416        ));
3417    }
3418    let _ = (feature_id, layout_id, target);
3419    let layout_path = root.join("templates/layout/base.html");
3420    let mut layout_params = serde_json::Map::from_iter([
3421        ("current_lang".to_owned(), Value::String("en".to_owned())),
3422        (
3423            "title".to_owned(),
3424            Value::String(super::json_commands::layout_browser_title(&root, title)),
3425        ),
3426        ("body".to_owned(), Value::String(body)),
3427        ("has_user".to_owned(), Value::Bool(has_user)),
3428    ]);
3429    for (key, value) in layout_role_nav_params(&layout_path) {
3430        layout_params.insert(key, value);
3431    }
3432    insert_actor_role_nav_params(app, &root, actor_id, &mut layout_params)?;
3433    let rendered =
3434        super::json_commands::template_render_file(&layout_path, &Value::Object(layout_params))
3435            .ok_or_else(|| FeatureRuntimeError::RenderFailed(page_id.to_owned()))?;
3436    let html = super::json_commands::template_apply_base_path(&rendered, app.base_path.as_deref())
3437        .ok_or_else(|| FeatureRuntimeError::RenderFailed(page_id.to_owned()))?;
3438    Ok(super::dynamic::csrf_inject_hidden_inputs(
3439        &root, &html, csrf_token,
3440    ))
3441}
3442
3443/// Renders a full feature page.
3444///
3445/// # Parameters
3446///
3447/// - `app`: Input consumed by [`crate::runtime::feature_runtime::render_feature_page_html`].
3448/// - `route`: Input consumed by [`crate::runtime::feature_runtime::render_feature_page_html`].
3449/// - `target`: Input consumed by [`crate::runtime::feature_runtime::render_feature_page_html`].
3450/// - `actor_id`: Input consumed by [`crate::runtime::feature_runtime::render_feature_page_html`].
3451/// - `has_user`: Input consumed by [`crate::runtime::feature_runtime::render_feature_page_html`].
3452/// - `csrf_token`: Input consumed by [`crate::runtime::feature_runtime::render_feature_page_html`].
3453///
3454/// # Returns
3455///
3456/// Returns the successful value produced by [`crate::runtime::feature_runtime::render_feature_page_html`] or an error when the operation fails.
3457///
3458/// # Errors
3459///
3460/// Returns an error value when [`crate::runtime::feature_runtime::render_feature_page_html`] cannot complete successfully.
3461///
3462/// # Examples
3463///
3464/// ```rust,ignore
3465/// let _result = pharos_native::runtime::feature_runtime::render_feature_page_html(todo!(), todo!(), "/", 1, todo!(), todo!());
3466/// ```
3467///
3468/// # Related
3469///
3470/// - [`crate::runtime::feature_runtime`] for adjacent helpers that share the same runtime surface.
3471pub fn render_feature_page_html(
3472    app: &AppRuntime,
3473    route: &Value,
3474    target: &str,
3475    actor_id: i64,
3476    has_user: bool,
3477    csrf_token: &str,
3478) -> Result<String, FeatureRuntimeError> {
3479    render_feature_page_html_with_error(app, route, target, actor_id, has_user, csrf_token, None)
3480}
3481
3482#[cfg(test)]
3483mod tests {
3484    /// Projects only declared relation fields and bounds typed IN predicates without broadening empty selections.
3485    #[test]
3486    fn relation_projection_and_membership_are_bounded_and_typed() {
3487        use serde_json::json;
3488        let mut context = super::FeatureRequestContext::default();
3489        context.bindings.insert(
3490            "links".into(),
3491            json!([{"id":1,"private":"not selected"},{"id":2,"private":"not selected"}]),
3492        );
3493        let before =
3494            crate::runtime::runtime_timestamp_seconds(&crate::runtime::current_timestamp_utc())
3495                .unwrap()
3496                .saturating_sub(30 * 86400);
3497        let cutoff =
3498            super::resolve_value(&json!({"server_time_offset_seconds":-2592000}), &context)
3499                .unwrap()
3500                .unwrap();
3501        let after =
3502            crate::runtime::runtime_timestamp_seconds(&crate::runtime::current_timestamp_utc())
3503                .unwrap()
3504                .saturating_sub(30 * 86400);
3505        let actual = crate::runtime::runtime_timestamp_seconds(cutoff.as_str().unwrap()).unwrap();
3506        assert!((before..=after).contains(&actual));
3507        for invalid in [
3508            json!({"server_time_offset_seconds":"-2592000"}),
3509            json!({"server_time_offset_seconds":i64::MIN}),
3510            json!({"server_time_offset_seconds":0,"extra":true}),
3511        ] {
3512            assert!(super::resolve_value(&invalid, &context).is_err());
3513        }
3514        let expression = json!({"pluck":{"source":"query.links","field":"id"}});
3515        assert_eq!(
3516            super::resolve_value(&expression, &context).unwrap(),
3517            Some(json!([1, 2]))
3518        );
3519        let schema=serde_json::from_value(json!({"app_id":"relation_test","entities":[{"name":"record","identity":{"auto_integer":"id"},"fields":[{"name":"id","kind":"integer","nullable":false,"constraints":[]}],"unique":[],"indexes":[],"conditional_indexes":[]}]})).unwrap();
3520        let filters = json!({"id":{"operator":"in","value":expression}});
3521        assert!(
3522            super::logical::predicate_for_filters(filters.as_object(), &context, &schema, "record")
3523                .unwrap()
3524                .is_some()
3525        );
3526        context.bindings.insert("links".into(), json!([]));
3527        assert_eq!(
3528            super::resolve_value(&expression, &context).unwrap(),
3529            Some(json!([]))
3530        );
3531        assert!(
3532            super::logical::predicate_for_filters(filters.as_object(), &context, &schema, "record")
3533                .unwrap()
3534                .is_some()
3535        );
3536        context
3537            .bindings
3538            .insert("links".into(), json!([{"id":"1 OR 1=1"}]));
3539        assert!(
3540            super::logical::predicate_for_filters(filters.as_object(), &context, &schema, "record")
3541                .is_err()
3542        );
3543        context
3544            .bindings
3545            .insert("links".into(), json!([{"unrelated":1}]));
3546        assert!(super::resolve_value(&expression, &context).is_err());
3547        context
3548            .bindings
3549            .insert("links".into(), json!(vec![json!({"id":1}); 65]));
3550        assert!(super::resolve_value(&expression, &context).is_err());
3551        for offset in ["UTC", "+14:00", "-05:30"] {
3552            assert!(super::declared_input_format_valid(Some("utc_offset"), offset).unwrap());
3553        }
3554        let choice =
3555            json!({"lookup":{"key":"query_param.type","values":{"DJ":["DJ","dj"],"all":null}}});
3556        context.query_params.insert("type".into(), json!("DJ"));
3557        assert_eq!(
3558            super::resolve_value(&choice, &context).unwrap(),
3559            Some(json!(["DJ", "dj"]))
3560        );
3561        context.query_params.insert("type".into(), json!("all"));
3562        assert_eq!(super::resolve_value(&choice, &context).unwrap(), None);
3563        context
3564            .query_params
3565            .insert("type".into(), json!("unrecognized"));
3566        assert!(super::resolve_value(&choice, &context).is_err());
3567        for offset in ["+24:00", "+01:99", "Europe/Unknown", "+é:10"] {
3568            assert!(!super::declared_input_format_valid(Some("utc_offset"), offset).unwrap());
3569        }
3570    }
3571    /// Retains source date precision and validates real calendar days without admitting ambiguous local clock times.
3572    #[test]
3573    fn date_or_timestamp_retains_day_precision() {
3574        use serde_json::json;
3575        let contract = json!({"inline_fields":{"start":{"type":"string","format":"date_or_timestamp","required":true},"end":{"type":"string","format":"date_or_timestamp","not_before":"start"}}});
3576        let mut request = crate::runtime::http::HttpRequest {
3577            method: "POST".into(),
3578            content_type: Some("application/json".into()),
3579            ..Default::default()
3580        };
3581        for value in ["2026-10-17", "2026-10-17T20:00:00Z"] {
3582            request.body = serde_json::to_vec(&json!({"start":value,"end":""})).unwrap();
3583            let parsed = super::validated_action_input(&request, &contract, None, "save").unwrap();
3584            assert!(
3585                parsed["start"]
3586                    .as_str()
3587                    .unwrap()
3588                    .starts_with(&value[..value.len().min(19)])
3589            );
3590        }
3591        for value in ["2026-02-30", "2026-10-17T20:00:00", "not a date"] {
3592            assert!(!super::declared_input_format_valid(Some("date_or_timestamp"), value).unwrap());
3593        }
3594        request.body =
3595            serde_json::to_vec(&json!({"start":"2026-10-17","end":"2026-10-16"})).unwrap();
3596        assert!(super::validated_action_input(&request, &contract, None, "save").is_err());
3597    }
3598
3599    /// Normalizes explicit offsets and rejects ambiguous or reversed event times before persistence.
3600    #[test]
3601    fn timestamp_inputs_require_zones_and_ordered_end_times() {
3602        for invalid in ["2027-01-01 20:00:00", "1800000000", "2027-02-30T20:00:00Z"] {
3603            assert!(!super::declared_input_format_valid(Some("timestamp"), invalid).unwrap());
3604        }
3605        let mut request = crate::runtime::http::HttpRequest {
3606            method: "POST".into(),
3607            target: "/save".into(),
3608            content_type: Some("application/json".into()),
3609            ..Default::default()
3610        };
3611        let contract = serde_json::json!({"inline_fields":{"start":{"type":"string","format":"timestamp","required":true},"end":{"type":"string","format":"timestamp","not_before":"start"}}});
3612        request.body = serde_json::to_vec(
3613            &serde_json::json!({"start":"2027-01-01T20:00:00+01:00","end":"2027-01-01T20:00:00Z"}),
3614        )
3615        .unwrap();
3616        let parsed = super::validated_action_input(&request, &contract, None, "save").unwrap();
3617        assert!(
3618            parsed["start"]
3619                .as_str()
3620                .unwrap()
3621                .starts_with("2027-01-01T19:00:00")
3622        );
3623        request.body = serde_json::to_vec(
3624            &serde_json::json!({"start":"2027-01-01T20:00:00Z","end":"2027-01-01T19:00:00Z"}),
3625        )
3626        .unwrap();
3627        assert!(super::validated_action_input(&request, &contract, None, "save").is_err());
3628        request.body =
3629            serde_json::to_vec(&serde_json::json!({"start":"2027-01-01T20:00:00","end":""}))
3630                .unwrap();
3631        assert!(super::validated_action_input(&request, &contract, None, "save").is_err());
3632    }
3633    /// Existing limits remain fixed unless an authored string field explicitly admits a bounded biography.
3634    #[test]
3635    fn declared_large_text_admits_biographies_with_fixed_request_and_field_bounds() {
3636        let mut request = crate::runtime::http::HttpRequest {
3637            method: "POST".into(),
3638            target: "/save".into(),
3639            content_type: Some("application/json".into()),
3640            ..Default::default()
3641        };
3642        request.body =
3643            serde_json::to_vec(&serde_json::json!({"biography":"a".repeat(9000)})).unwrap();
3644        let mut contract =
3645            serde_json::json!({"inline_fields":{"biography":{"type":"string","max_length":32768}}});
3646        assert!(super::validated_action_input(&request, &contract, None, "save").is_err());
3647        contract["inline_fields"]["biography"]["large_text"] = serde_json::json!(true);
3648        assert_eq!(
3649            super::validated_action_input(&request, &contract, None, "save").unwrap()["biography"]
3650                .as_str()
3651                .unwrap()
3652                .len(),
3653            9000
3654        );
3655        request.body =
3656            serde_json::to_vec(&serde_json::json!({"biography":"a".repeat(32769)})).unwrap();
3657        assert!(super::validated_action_input(&request, &contract, None, "save").is_err());
3658    }
3659    /// Validates CRM contacts and public sources before any declared mutation executes.
3660    #[test]
3661    fn declared_contact_formats_reject_unsafe_links_and_bad_contacts() {
3662        assert!(super::declared_input_format_valid(Some("email"), "person@example.org").unwrap());
3663        assert!(!super::declared_input_format_valid(Some("email"), "not-an-email").unwrap());
3664        assert!(super::declared_input_format_valid(Some("email"), "").unwrap());
3665        assert!(
3666            super::declared_input_format_valid(Some("https_url"), "https://events.example/path")
3667                .unwrap()
3668        );
3669        assert!(
3670            !super::declared_input_format_valid(Some("https_url"), "javascript:alert(1)").unwrap()
3671        );
3672        assert!(
3673            !super::declared_input_format_valid(
3674                Some("https_url"),
3675                "https://secret@example.org/path"
3676            )
3677            .unwrap()
3678        );
3679        assert!(super::declared_input_format_valid(Some("unknown"), "").is_err());
3680    }
3681
3682    use serde_json::Value;
3683
3684    use super::{
3685        FeatureRequestContext, FeatureRuntimeError, action_redirect_path, execute_feature_action,
3686        manifest_declared_template_params, path_parameters, query_parameters,
3687    };
3688
3689    /// Checks typed table defaults, checked arithmetic and exact monetary values before workflow use.
3690    #[test]
3691    fn bounded_site_value_sources_preserve_typed_defaults_and_money() {
3692        let mut context = FeatureRequestContext {
3693            request_target: "/".into(),
3694            actor_id: 1,
3695            input: Default::default(),
3696            path_params: Default::default(),
3697            query_params: Default::default(),
3698            bindings: Default::default(),
3699            row: Default::default(),
3700            generated: Default::default(),
3701            protected: Default::default(),
3702        };
3703        let source = serde_json::json!({"coalesce":["path.table_id",1]});
3704        assert_eq!(
3705            super::resolve_value(&source, &context).unwrap(),
3706            Some(Value::from(1))
3707        );
3708        context
3709            .path_params
3710            .insert("table_id".into(), Value::from(2));
3711        assert_eq!(
3712            super::resolve_value(&source, &context).unwrap(),
3713            Some(Value::from(2))
3714        );
3715        context
3716            .path_params
3717            .insert("table_id".into(), Value::from("invalid"));
3718        assert_eq!(
3719            super::resolve_value(&source, &context).unwrap(),
3720            Some(Value::from("invalid"))
3721        );
3722        assert_eq!(
3723            super::resolve_value(&serde_json::json!({"add":[40,-1]}), &context).unwrap(),
3724            Some(Value::from(39))
3725        );
3726        assert!(super::resolve_value(&serde_json::json!({"add":[i64::MAX,1]}), &context).is_err());
3727        assert!(
3728            super::resolve_value(&serde_json::json!({"add":["untrusted",1]}), &context).is_err()
3729        );
3730        for (currency, expected) in [("USD", "$12.50"), ("JPY", "Â¥1250")] {
3731            assert_eq!(
3732                super::resolve_value(
3733                    &serde_json::json!({"format_minor":{"amount":1250,"currency":currency}}),
3734                    &context
3735                )
3736                .unwrap(),
3737                Some(Value::from(expected))
3738            );
3739            assert_eq!(
3740                crate::runtime::json_commands::render_template_text_with_default_kind(
3741                    "{currency_minor:amount}",
3742                    &serde_json::json!({"amount":1250,"currency":currency}),
3743                    "text"
3744                )
3745                .as_deref(),
3746                Some(expected)
3747            );
3748        }
3749        assert!(
3750            super::resolve_value(
3751                &serde_json::json!({"format_minor":{"amount":-1,"currency":"USD"}}),
3752                &context
3753            )
3754            .is_err()
3755        );
3756        let mut nested = Value::from(1);
3757        for _ in 0..33 {
3758            nested = serde_json::json!({"coalesce":[nested]});
3759        }
3760        assert!(super::resolve_value(&nested, &context).is_err());
3761    }
3762
3763    /// Verifies scalar outputs from later-sorted queries are available in dependent row and empty templates.
3764    #[test]
3765    fn feature_rows_receive_cross_query_scalar_outputs() {
3766        let root = test_root("query-output-alias");
3767        std::fs::create_dir_all(root.join("templates")).unwrap();
3768        std::fs::write(
3769            root.join("templates/alias_row.html"),
3770            "{current_table_id}:{title}",
3771        )
3772        .unwrap();
3773        std::fs::write(
3774            root.join("templates/alias_empty.html"),
3775            "{current_table_id}",
3776        )
3777        .unwrap();
3778        let feature = serde_json::json!({"queries": {
3779            "a_items":{"render":{"template":"templates/alias_row.html","empty_template":"templates/alias_empty.html","output":"item_rows"}},
3780            "z_table":{"outputs":{"current_table_id":"id"}}
3781        }});
3782        let mut results = std::collections::BTreeMap::from([
3783            (
3784                "a_items".into(),
3785                vec![serde_json::Map::from_iter([(
3786                    "title".into(),
3787                    Value::from("Pizza"),
3788                )])],
3789            ),
3790            (
3791                "z_table".into(),
3792                vec![serde_json::Map::from_iter([("id".into(), Value::from(2))])],
3793            ),
3794        ]);
3795        let params =
3796            super::query_render_params(&root, &feature, &results, &Default::default()).unwrap();
3797        assert_eq!(params["item_rows"], "2:Pizza");
3798        results.insert("a_items".into(), vec![]);
3799        let params =
3800            super::query_render_params(&root, &feature, &results, &Default::default()).unwrap();
3801        assert_eq!(params["item_rows"], "2");
3802        std::fs::remove_dir_all(root).unwrap();
3803    }
3804
3805    #[test]
3806    /// Resolves an app-owned redirect without allowing a path value to escape its segment.
3807    fn declared_redirect_keeps_path_parameter_in_one_segment() {
3808        let parameters = serde_json::Map::from_iter([(
3809            "business_slug".to_owned(),
3810            Value::String("one?two".to_owned()),
3811        )]);
3812        assert_eq!(
3813            action_redirect_path("/businesses/:business_slug/services/", &parameters)
3814                .expect("encoded redirect"),
3815            "/businesses/one%3Ftwo/services/"
3816        );
3817        assert!(action_redirect_path("/businesses/:missing/", &parameters).is_err());
3818        let traversal = serde_json::Map::from_iter([(
3819            "business_slug".to_owned(),
3820            Value::String("..".to_owned()),
3821        )]);
3822        assert!(action_redirect_path("/businesses/:business_slug/", &traversal).is_err());
3823    }
3824
3825    /// Creates one unique filesystem root for a direct feature runtime test.
3826    fn test_root(label: &str) -> std::path::PathBuf {
3827        let nonce = std::time::SystemTime::now()
3828            .duration_since(std::time::UNIX_EPOCH)
3829            .expect("clock")
3830            .as_nanos();
3831        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3832            .join("tmp")
3833            .join(format!(
3834                "pharos-feature-runtime-{label}-{}-{nonce}",
3835                std::process::id()
3836            ))
3837    }
3838
3839    /// Writes one test fixture below a unique temporary root.
3840    fn write(root: &std::path::Path, relative: &str, content: &str) {
3841        let path = root.join(relative);
3842        std::fs::create_dir_all(path.parent().expect("parent")).expect("directories");
3843        std::fs::write(path, content).expect("fixture");
3844        if relative == "data/contracts/entities.json" {
3845            write_logical_test_contract(root, content);
3846        }
3847    }
3848
3849    /// Applies the declared logical schema and seed records through one adapter.
3850    fn initialize_logical_test_records(root: &std::path::Path, records: &Value) -> i32 {
3851        let contract = pharos_runtime_database::contract::load_contract(
3852            &root.join("data/contracts/database.json"),
3853            "feature_test",
3854        )
3855        .expect("logical feature test contract");
3856        let store = super::super::database::logical_app_store(root, None, "feature_test")
3857            .expect("feature test operation store");
3858        store
3859            .adapter
3860            .apply_contract(&store.config, &contract)
3861            .expect("apply feature schema");
3862        let records: Vec<pharos_runtime_database::operations::Record> =
3863            serde_json::from_value(records.clone()).expect("logical feature seeds");
3864        if !records.is_empty() {
3865            store
3866                .adapter
3867                .import_batch(&store.config, &store.schema, &records)
3868                .expect("feature seed records");
3869        }
3870        0
3871    }
3872
3873    /// Checks changed records without a vendor query or projection export.
3874    fn logical_test_value(
3875        root: &std::path::Path,
3876        entity: &str,
3877        field: &str,
3878        filter: Option<pharos_runtime_database::operations::Predicate>,
3879    ) -> Option<String> {
3880        use pharos_runtime_database::operations::{Aggregate, AggregateQuery, Query};
3881        let store = super::super::database::logical_app_store(root, None, "feature_test")
3882            .expect("feature test operation store");
3883        let value = if field == "COUNT(*)" {
3884            store
3885                .adapter
3886                .aggregate(
3887                    &store.config,
3888                    &store.schema,
3889                    &AggregateQuery {
3890                        entity: entity.to_owned(),
3891                        filter,
3892                        aggregate: Aggregate::Count,
3893                    },
3894                )
3895                .ok()?
3896        } else {
3897            store
3898                .adapter
3899                .query(
3900                    &store.config,
3901                    &store.schema,
3902                    &Query {
3903                        entity: entity.to_owned(),
3904                        fields: vec![field.to_owned()],
3905                        filter,
3906                        order: Vec::new(),
3907                        limit: 1,
3908                        offset: 0,
3909                    },
3910                )
3911                .ok()?
3912                .first()?
3913                .get(field)?
3914                .clone()
3915        };
3916        Some(
3917            value
3918                .as_str()
3919                .map(ToOwned::to_owned)
3920                .unwrap_or_else(|| value.to_string()),
3921        )
3922    }
3923
3924    /// Declares the test schema explicitly so feature tests exercise the logical executor.
3925    fn write_logical_test_contract(root: &std::path::Path, content: &str) {
3926        use pharos_runtime_database::contract::{
3927            AppDataContract, migration_checksum, schema_from_entity_catalog,
3928        };
3929        use pharos_runtime_database::operations::{Migration, SchemaChange};
3930        let mut document: Value = serde_json::from_str(content).expect("entity catalog");
3931        for entity in document["entities"].as_array_mut().expect("entities") {
3932            for field in entity["fields"].as_array_mut().expect("fields") {
3933                let name = field["field_id"].as_str().expect("field name").to_owned();
3934                let kind = match name.as_str() {
3935                    "id" | "user_id" | "cart_id" | "product_id" | "price_minor"
3936                    | "available_quantity" => "integer",
3937                    "added_at" | "reserved_at" | "released_at" | "confirmed_at" => "timestamp",
3938                    "title" | "slug" | "status" | "reservation_status" | "release_reason" => "text",
3939                    _ => panic!("missing logical test field declaration: {name}"),
3940                };
3941                let nullable = matches!(name.as_str(), "released_at" | "confirmed_at");
3942                field["field_type"] = Value::String(kind.to_owned());
3943                field["nullable"] = Value::Bool(nullable);
3944                if name == "release_reason" {
3945                    field["field_default"] = serde_json::json!({"literal":""});
3946                }
3947            }
3948            entity["unique_keys"] = match entity["entity_id"].as_str().expect("entity name") {
3949                "cart" => serde_json::json!([["user_id"]]),
3950                "cart_item" => serde_json::json!([["cart_id", "product_id"]]),
3951                _ => serde_json::json!([]),
3952            };
3953        }
3954        let names = document["entities"]
3955            .as_array()
3956            .expect("entities")
3957            .iter()
3958            .map(|entity| entity["entity_id"].as_str().expect("entity name"))
3959            .collect::<Vec<_>>();
3960        let schema =
3961            schema_from_entity_catalog(&document, "feature_test", &names).expect("logical schema");
3962        let changes = schema
3963            .entities
3964            .iter()
3965            .cloned()
3966            .map(SchemaChange::CreateEntity)
3967            .collect::<Vec<_>>();
3968        let migration = Migration {
3969            app_id: "feature_test".to_owned(),
3970            id: "feature_schema".to_owned(),
3971            checksum: migration_checksum("feature_test", "feature_schema", &changes)
3972                .expect("checksum"),
3973            changes,
3974        };
3975        let contract = AppDataContract {
3976            schema,
3977            migrations: vec![migration],
3978            legacy_adoption: None,
3979        };
3980        std::fs::write(
3981            root.join("data/contracts/database.json"),
3982            serde_json::to_vec(&contract).expect("contract JSON"),
3983        )
3984        .expect("logical contract");
3985    }
3986
3987    #[test]
3988    /// Keeps untrusted query text in a typed predicate for adapter-owned binding.
3989    fn query_values_are_bound_and_never_interpolated() {
3990        use pharos_runtime_database::operations::{
3991            AppSchema, Entity, Field, FieldType, Identity, Predicate,
3992        };
3993        let fields = ["id", "title", "status"]
3994            .into_iter()
3995            .map(|name| Field {
3996                name: name.to_owned(),
3997                kind: if name == "id" {
3998                    FieldType::Integer
3999                } else {
4000                    FieldType::Text
4001                },
4002                nullable: false,
4003                max_length: None,
4004                default: None,
4005                references: None,
4006                on_delete: None,
4007                constraints: Vec::new(),
4008            })
4009            .collect();
4010        let schema = AppSchema {
4011            app_id: "feature_test".to_owned(),
4012            entities: vec![Entity {
4013                name: "item".to_owned(),
4014                identity: Identity::AutoInteger("id".to_owned()),
4015                fields,
4016                unique: Vec::new(),
4017                indexes: Vec::new(),
4018                conditional_indexes: Vec::new(),
4019            }],
4020        };
4021        let context = FeatureRequestContext {
4022            query_params: serde_json::Map::from_iter([(
4023                "q".to_owned(),
4024                Value::String("%' OR 1=1 --".to_owned()),
4025            )]),
4026            ..FeatureRequestContext::default()
4027        };
4028        let filters =
4029            serde_json::json!({"title": {"value": {"query_param": "q"}, "operator": "contains"}});
4030        let predicate =
4031            super::logical::predicate_for_filters(filters.as_object(), &context, &schema, "item")
4032                .expect("typed predicate");
4033        assert_eq!(
4034            predicate,
4035            Some(Predicate::Contains(
4036                "title".to_owned(),
4037                "%' OR 1=1 --".to_owned()
4038            ))
4039        );
4040        let filters = serde_json::json!({"status": {"operator": "is_null"}});
4041        let predicate =
4042            super::logical::predicate_for_filters(filters.as_object(), &context, &schema, "item")
4043                .expect("typed null eligibility");
4044        assert_eq!(predicate, Some(Predicate::IsNull("status".to_owned())));
4045        let filters = serde_json::json!({"unknown": {"operator": "is_null"}});
4046        assert!(
4047            super::logical::predicate_for_filters(filters.as_object(), &context, &schema, "item")
4048                .is_err()
4049        );
4050    }
4051
4052    #[test]
4053    /// Performs query and path inputs are bounded and duplicate free.
4054    fn query_and_path_inputs_are_bounded_and_duplicate_free() {
4055        let query = query_parameters("/?genre=house&q=kick+loop").expect("query");
4056        assert_eq!(query.get("q").and_then(Value::as_str), Some("kick loop"));
4057        assert!(query_parameters("/?q=one&q=two").is_err());
4058        let path = path_parameters("/packs/:slug", "/packs/neon-house").expect("path");
4059        assert_eq!(path.get("slug").and_then(Value::as_str), Some("neon-house"));
4060        assert!(path_parameters("/packs/:slug", "/packs/a/b").is_err());
4061    }
4062
4063    #[test]
4064    /// Performs manifest declared payment copy exposes bounded template params.
4065    fn manifest_declared_payment_copy_exposes_template_params() {
4066        let root = test_root("payment-copy");
4067        std::fs::create_dir_all(&root).expect("root");
4068        std::fs::create_dir_all(root.join("data")).expect("data");
4069        write(
4070            &root,
4071            "pharos.app.json",
4072            r#"{"app_id":"feature_test","payments":{"checkout_mode":"demo","supported_providers":["stripe"],"provider_src":"data/payment_provider.yml","copy":{"checkout_headline":"Demo checkout","owner_gross_label":"Gross demo sales"}}}"#,
4073        );
4074        write(
4075            &root,
4076            "data/payment_provider.yml",
4077            "owner_resource_type: merchant\nsupported_providers:\n  - provider: stripe\ncopy:\n  owner_payment_provider_setup_headline: Connect Payment Provider\n",
4078        );
4079        let app = crate::config::AppRuntime {
4080            app_id: Some("feature_test".to_owned()),
4081            app_root: Some(root.to_string_lossy().into_owned()),
4082            artifact_dir: Some(root.to_string_lossy().into_owned()),
4083            ..crate::config::AppRuntime::default()
4084        };
4085        let params = manifest_declared_template_params(&app).expect("manifest params");
4086        assert_eq!(
4087            params.get("payment_checkout_mode").and_then(Value::as_str),
4088            Some("Demo")
4089        );
4090        assert_eq!(
4091            params
4092                .get("payment_primary_provider")
4093                .and_then(Value::as_str),
4094            Some("stripe")
4095        );
4096        assert_eq!(
4097            params.get("owner_gross_label").and_then(Value::as_str),
4098            Some("Gross demo sales")
4099        );
4100        assert_eq!(
4101            params
4102                .get("owner_payment_provider_setup_headline")
4103                .and_then(Value::as_str),
4104            Some("Connect Payment Provider")
4105        );
4106        std::fs::remove_dir_all(root).expect("cleanup");
4107    }
4108
4109    #[cfg(all(feature = "sqlite", not(target_os = "wasi")))]
4110    #[test]
4111    /// Performs feature action commits once and renders refreshed fragment.
4112    fn feature_action_commits_once_and_renders_refreshed_fragment() {
4113        let root = test_root("action");
4114        let database_path = root.join("feature.sqlite3");
4115        std::fs::create_dir_all(&root).expect("root");
4116        write(
4117            &root,
4118            "pharos.app.yml",
4119            "app_id: feature_test\nname: Feature Test\nversion: 1.0.0\ndatabase:\n  migration_contract: data/contracts/database.json\n",
4120        );
4121        write(
4122            &root,
4123            "app.conf",
4124            &format!("db_backend=sqlite\ndb_path={}\n", database_path.display()),
4125        );
4126        write(
4127            &root,
4128            "data/contracts/migrations.json",
4129            r#"{"state_cutover_mode":"relational_runtime_authority"}"#,
4130        );
4131        write(
4132            &root,
4133            "data/contracts/entities.json",
4134            r#"{"entities":[
4135              {"entity_id":"product","table":"product","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"status"}]},
4136              {"entity_id":"cart","table":"cart","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"user_id"}]},
4137              {"entity_id":"cart_item","table":"cart_item","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"cart_id"},{"field_id":"user_id"},{"field_id":"product_id"}]}
4138            ]}"#,
4139        );
4140        write(
4141            &root,
4142            "data/contracts/features.json",
4143            r#"{"features":[{"feature_id":"cart","queries":{
4144              "published_product":{"entity":"product","fields":["id","status"],"filters":{"id":"input.product_id","status":"published"},"result":"one"},
4145              "current_cart":{"entity":"cart","fields":["id","user_id"],"filters":{"user_id":"subject.user_id"},"result":"one"},
4146              "current_cart_items":{"entity":"cart_item","fields":["id","user_id","product_id"],"filters":{"user_id":"subject.user_id"},"result":"many","limit":20}
4147            }}]}"#,
4148        );
4149        write(
4150            &root,
4151            "data/contracts/actions.json",
4152            r#"{"actions":[{"action_id":"add_item","feature_id":"cart","input_contract":{"inline_fields":{"product_id":{"type":"integer","required":true,"minimum":1}},"reject_unknown":true},"transaction_contract":{"require":{"query":"published_product"},"upsert":{"entity":"cart_item","unique_by":["cart_id","product_id"],"values":{"cart_id":"query.current_cart.id","user_id":"subject.user_id","product_id":"input.product_id"}}},"response_contract":{"response_kind":"fragment","template":"templates/fragments/cart_summary.html","target":"cart-panel","queries":["current_cart_items"],"fallback":{"redirect":"/"}}}]}"#,
4153        );
4154        write(
4155            &root,
4156            "templates/fragments/cart_summary.html",
4157            r#"<aside id="cart-panel">{current_cart_items_count}</aside>"#,
4158        );
4159        assert_eq!(
4160            initialize_logical_test_records(
4161                &root,
4162                &serde_json::json!([{"entity":"product","fields":{"id":7,"status":"published"}},{"entity":"cart","fields":{"id":3,"user_id":42}}])
4163            ),
4164            0
4165        );
4166        let app = crate::config::AppRuntime {
4167            app_id: Some("feature_test".to_owned()),
4168            app_root: Some(root.to_string_lossy().into_owned()),
4169            artifact_dir: Some(root.to_string_lossy().into_owned()),
4170            ..crate::config::AppRuntime::default()
4171        };
4172        let route = serde_json::json!({
4173            "path":"/cart/items",
4174            "feature_id":"cart",
4175            "action_id":"add_item"
4176        });
4177        let request = super::super::http::HttpRequest {
4178            method: "POST".to_owned(),
4179            target: "/cart/items".to_owned(),
4180            content_type: Some("application/x-www-form-urlencoded".to_owned()),
4181            body: b"product_id=7&csrf_token=test".to_vec(),
4182            ..super::super::http::HttpRequest::default()
4183        };
4184        let first = execute_feature_action(&app, &route, &request, 42).expect("first action");
4185        let second = execute_feature_action(&app, &route, &request, 42).expect("idempotent action");
4186        assert!(first.fragment_html.contains(">1</aside>"));
4187        assert_eq!(second.fragment_html, first.fragment_html);
4188        assert_eq!(
4189            logical_test_value(&root, "cart_item", "COUNT(*)", None).as_deref(),
4190            Some("1")
4191        );
4192        std::fs::remove_dir_all(root).expect("cleanup");
4193    }
4194
4195    #[cfg(all(feature = "sqlite", not(target_os = "wasi")))]
4196    #[test]
4197    /// Performs feature action accepts JSON input for declared fields.
4198    fn feature_action_accepts_json_input_for_declared_fields() {
4199        let root = test_root("json-action");
4200        let database_path = root.join("feature.sqlite3");
4201        std::fs::create_dir_all(&root).expect("root");
4202        write(
4203            &root,
4204            "pharos.app.yml",
4205            "app_id: feature_test\nname: Feature Test\nversion: 1.0.0\ndatabase:\n  migration_contract: data/contracts/database.json\n",
4206        );
4207        write(
4208            &root,
4209            "app.conf",
4210            &format!("db_backend=sqlite\ndb_path={}\n", database_path.display()),
4211        );
4212        write(
4213            &root,
4214            "data/contracts/migrations.json",
4215            r#"{"state_cutover_mode":"relational_runtime_authority"}"#,
4216        );
4217        write(
4218            &root,
4219            "data/contracts/entities.json",
4220            r#"{"entities":[
4221              {"entity_id":"product","table":"product","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"status"}]},
4222              {"entity_id":"cart","table":"cart","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"user_id"}]},
4223              {"entity_id":"cart_item","table":"cart_item","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"cart_id"},{"field_id":"user_id"},{"field_id":"product_id"}]}
4224            ]}"#,
4225        );
4226        write(
4227            &root,
4228            "data/contracts/features.json",
4229            r#"{"features":[{"feature_id":"cart","queries":{
4230              "published_product":{"entity":"product","fields":["id","status"],"filters":{"id":"input.product_id","status":"published"},"result":"one"},
4231              "current_cart":{"entity":"cart","fields":["id","user_id"],"filters":{"user_id":"subject.user_id"},"result":"one"},
4232              "current_cart_items":{"entity":"cart_item","fields":["id","user_id","product_id"],"filters":{"user_id":"subject.user_id"},"result":"many","limit":20}
4233            }}]}"#,
4234        );
4235        write(
4236            &root,
4237            "data/contracts/actions.json",
4238            r#"{"actions":[{"action_id":"add_item","feature_id":"cart","input_contract":{"inline_fields":{"product_id":{"type":"integer","required":true,"minimum":1}},"reject_unknown":true},"transaction_contract":{"require":{"query":"published_product"},"upsert":{"entity":"cart_item","unique_by":["cart_id","product_id"],"values":{"cart_id":"query.current_cart.id","user_id":"subject.user_id","product_id":"input.product_id"}}},"response_contract":{"response_kind":"fragment","template":"templates/fragments/cart_summary.html","target":"cart-panel","queries":["current_cart_items"],"fallback":{"redirect":"/"}},"guard_contract":{"guards":[]}}]}"#,
4239        );
4240        write(
4241            &root,
4242            "templates/fragments/cart_summary.html",
4243            r#"<aside id="cart-panel">{current_cart_items_count}</aside>"#,
4244        );
4245        assert_eq!(
4246            initialize_logical_test_records(
4247                &root,
4248                &serde_json::json!([{"entity":"product","fields":{"id":7,"status":"published"}},{"entity":"cart","fields":{"id":3,"user_id":42}}])
4249            ),
4250            0
4251        );
4252        let app = crate::config::AppRuntime {
4253            app_id: Some("feature_test".to_owned()),
4254            app_root: Some(root.to_string_lossy().into_owned()),
4255            artifact_dir: Some(root.to_string_lossy().into_owned()),
4256            ..crate::config::AppRuntime::default()
4257        };
4258        let route = serde_json::json!({
4259            "path":"/cart/items",
4260            "feature_id":"cart",
4261            "action_id":"add_item"
4262        });
4263        let request = super::super::http::HttpRequest {
4264            method: "POST".to_owned(),
4265            target: "/cart/items".to_owned(),
4266            content_type: Some("application/json".to_owned()),
4267            body: br#"{"product_id":7}"#.to_vec(),
4268            ..super::super::http::HttpRequest::default()
4269        };
4270        let outcome = execute_feature_action(&app, &route, &request, 42).expect("json action");
4271        assert!(outcome.fragment_html.contains(">1</aside>"));
4272        write(
4273            &root,
4274            "templates/connections.page.json",
4275            r#"{"page_id":"connections","template_file":"templates/fragments/connections.html"}"#,
4276        );
4277        write(
4278            &root,
4279            "templates/fragments/connections.html",
4280            r#"<div hx-get="/__pharos/social/youtube/status"></div><a href="/feature-test/owner">Owner</a>"#,
4281        );
4282        let mut mounted = app.clone();
4283        mounted.base_path = Some("/feature-test".into());
4284        let page = serde_json::json!({"path":"/connections","feature_id":"cart","page_id":"connections","queries":[]});
4285        let fragment =
4286            super::render_feature_page_body(&mounted, &page, "/connections", 42, "csrf").unwrap();
4287        assert!(fragment.contains("hx-get=\"/feature-test/__pharos/social/youtube/status\""));
4288        assert!(fragment.contains("href=\"/feature-test/owner\""));
4289        assert!(!fragment.contains("/feature-test/feature-test/"));
4290        std::fs::remove_dir_all(root).expect("cleanup");
4291    }
4292
4293    #[cfg(all(feature = "sqlite", not(target_os = "wasi")))]
4294    #[test]
4295    /// Performs feature action normalizes declared string input.
4296    fn feature_action_normalizes_declared_string_input() {
4297        let root = test_root("normalized-action");
4298        let database_path = root.join("feature.sqlite3");
4299        std::fs::create_dir_all(&root).expect("root");
4300        write(
4301            &root,
4302            "pharos.app.yml",
4303            "app_id: feature_test\nname: Feature Test\nversion: 1.0.0\ndatabase:\n  migration_contract: data/contracts/database.json\n",
4304        );
4305        write(
4306            &root,
4307            "app.conf",
4308            &format!("db_backend=sqlite\ndb_path={}\n", database_path.display()),
4309        );
4310        write(
4311            &root,
4312            "data/contracts/migrations.json",
4313            r#"{"state_cutover_mode":"relational_runtime_authority"}"#,
4314        );
4315        write(
4316            &root,
4317            "data/contracts/entities.json",
4318            r#"{"entities":[
4319              {"entity_id":"note","table":"note","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"title"},{"field_id":"user_id"}]}
4320            ]}"#,
4321        );
4322        write(
4323            &root,
4324            "data/contracts/features.json",
4325            r#"{"features":[{"feature_id":"notes","queries":{
4326              "my_notes":{"entity":"note","fields":["id","title"],"filters":{"user_id":"subject.user_id"},"result":"many","limit":20}
4327            }}]}"#,
4328        );
4329        write(
4330            &root,
4331            "data/contracts/actions.json",
4332            r#"{"actions":[{"action_id":"add_note","feature_id":"notes","input_contract":{"inline_fields":{"title":{"type":"string","required":true,"min_length":1,"max_length":80}},"reject_unknown":true},"hook_contract":{"normalize_inputs":[{"kind":"trim","fields":["title"]},{"kind":"collapse_whitespace","fields":["title"]}]},"transaction_contract":{"insert":{"entity":"note","values":{"title":"input.title","user_id":"subject.user_id"}}},"response_contract":{"response_kind":"fragment","template":"templates/fragments/note_summary.html","target":"note-panel","queries":["my_notes"],"fallback":{"redirect":"/"}}}]}"#,
4333        );
4334        write(
4335            &root,
4336            "templates/fragments/note_summary.html",
4337            r#"<aside id="note-panel">{my_notes_count}</aside>"#,
4338        );
4339        assert_eq!(
4340            initialize_logical_test_records(&root, &serde_json::json!([])),
4341            0
4342        );
4343        let app = crate::config::AppRuntime {
4344            app_id: Some("feature_test".to_owned()),
4345            app_root: Some(root.to_string_lossy().into_owned()),
4346            artifact_dir: Some(root.to_string_lossy().into_owned()),
4347            ..crate::config::AppRuntime::default()
4348        };
4349        let route = serde_json::json!({
4350            "path":"/notes",
4351            "feature_id":"notes",
4352            "action_id":"add_note"
4353        });
4354        let request = super::super::http::HttpRequest {
4355            method: "POST".to_owned(),
4356            target: "/notes".to_owned(),
4357            content_type: Some("application/x-www-form-urlencoded".to_owned()),
4358            body: b"title=++Alpha+++Beta++&csrf_token=test".to_vec(),
4359            ..super::super::http::HttpRequest::default()
4360        };
4361        let outcome = execute_feature_action(&app, &route, &request, 42).expect("action");
4362        assert!(outcome.fragment_html.contains(">1</aside>"));
4363        assert_eq!(
4364            logical_test_value(
4365                &root,
4366                "note",
4367                "title",
4368                Some(pharos_runtime_database::operations::Predicate::Equal(
4369                    "user_id".to_owned(),
4370                    serde_json::json!(42)
4371                ))
4372            )
4373            .as_deref(),
4374            Some("Alpha Beta")
4375        );
4376        std::fs::remove_dir_all(root).expect("cleanup");
4377    }
4378
4379    #[test]
4380    /// Performs validated action input applies default inputs before required checks.
4381    fn validated_action_input_applies_default_inputs_before_required_checks() {
4382        let request = super::super::http::HttpRequest {
4383            method: "POST".to_owned(),
4384            target: "/snapshots/create".to_owned(),
4385            content_type: Some("application/x-www-form-urlencoded".to_owned()),
4386            body: b"csrf_token=test".to_vec(),
4387            ..super::super::http::HttpRequest::default()
4388        };
4389        let contract = serde_json::json!({
4390            "inline_fields": {
4391                "snapshot_label": {"type":"string","required":true,"min_length":1,"max_length":80}
4392            },
4393            "reject_unknown": true
4394        });
4395        let hook_contract = serde_json::json!({
4396            "default_inputs": [
4397                {"field":"snapshot_label","source":"literal","value":"initial-proof"}
4398            ]
4399        });
4400        let input = super::validated_action_input(
4401            &request,
4402            &contract,
4403            Some(&hook_contract),
4404            "snapshot_board_tasks",
4405        )
4406        .expect("default input");
4407        assert_eq!(
4408            input.get("snapshot_label").and_then(Value::as_str),
4409            Some("initial-proof")
4410        );
4411    }
4412
4413    #[test]
4414    /// Performs validated action input preserves explicit empty literal defaults for optional fields.
4415    fn validated_action_input_preserves_empty_literal_default_inputs() {
4416        let request = super::super::http::HttpRequest {
4417            method: "POST".to_owned(),
4418            target: "/snapshots/create".to_owned(),
4419            content_type: Some("application/x-www-form-urlencoded".to_owned()),
4420            body: b"title=Proof&csrf_token=test".to_vec(),
4421            ..super::super::http::HttpRequest::default()
4422        };
4423        let contract = serde_json::json!({
4424            "inline_fields": {
4425                "title": {"type":"string","required":true,"min_length":1,"max_length":80},
4426                "amount": {"type":"string","required":false,"max_length":120}
4427            },
4428            "reject_unknown": true
4429        });
4430        let hook_contract = serde_json::json!({
4431            "default_inputs": [
4432                {"field":"amount","source":"literal","value":""}
4433            ]
4434        });
4435        let input = super::validated_action_input(
4436            &request,
4437            &contract,
4438            Some(&hook_contract),
4439            "snapshot_board_tasks",
4440        )
4441        .expect("empty default input");
4442        assert_eq!(input.get("amount").and_then(Value::as_str), Some(""));
4443    }
4444
4445    #[test]
4446    /// Performs validated action input accepts derived header and query values.
4447    fn validated_action_input_accepts_derived_header_and_query_values() {
4448        let request = super::super::http::HttpRequest {
4449            method: "POST".to_owned(),
4450            target: "/notes/create?source=release-page".to_owned(),
4451            headers: "X-Feature-Title:   Header   Title  \r\n".to_owned(),
4452            content_type: Some("application/x-www-form-urlencoded".to_owned()),
4453            body: b"csrf_token=test".to_vec(),
4454            ..super::super::http::HttpRequest::default()
4455        };
4456        let contract = serde_json::json!({
4457            "inline_fields": {
4458                "title": {"type":"string","required":true,"min_length":1,"max_length":80},
4459                "source_label": {"type":"string","required":true,"min_length":1,"max_length":80}
4460            },
4461            "reject_unknown": true
4462        });
4463        let hook_contract = serde_json::json!({
4464            "derived_inputs": [
4465                {"field":"title","source":"header","header":"X-Feature-Title"},
4466                {"field":"source_label","source":"query","param":"source"}
4467            ],
4468            "normalize_inputs": [
4469                {"kind":"trim","fields":["title"]},
4470                {"kind":"collapse_whitespace","fields":["title"]}
4471            ]
4472        });
4473        let input =
4474            super::validated_action_input(&request, &contract, Some(&hook_contract), "add_note")
4475                .expect("derived input");
4476        assert_eq!(
4477            input.get("title").and_then(Value::as_str),
4478            Some("Header Title")
4479        );
4480        assert_eq!(
4481            input.get("source_label").and_then(Value::as_str),
4482            Some("release-page")
4483        );
4484    }
4485
4486    #[cfg(all(feature = "sqlite", not(target_os = "wasi")))]
4487    #[test]
4488    /// Performs feature action guard returns structured validation report.
4489    fn feature_action_guard_returns_structured_validation_report() {
4490        let root = test_root("guard-action");
4491        let database_path = root.join("feature.sqlite3");
4492        std::fs::create_dir_all(&root).expect("root");
4493        write(
4494            &root,
4495            "pharos.app.yml",
4496            "app_id: feature_test\nname: Feature Test\nversion: 1.0.0\ndatabase:\n  migration_contract: data/contracts/database.json\n",
4497        );
4498        write(
4499            &root,
4500            "app.conf",
4501            &format!("db_backend=sqlite\ndb_path={}\n", database_path.display()),
4502        );
4503        write(
4504            &root,
4505            "data/contracts/migrations.json",
4506            r#"{"state_cutover_mode":"relational_runtime_authority"}"#,
4507        );
4508        write(
4509            &root,
4510            "data/contracts/entities.json",
4511            r#"{"entities":[
4512              {"entity_id":"product","table":"product","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"status"},{"field_id":"price_minor"}]},
4513              {"entity_id":"cart","table":"cart","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"user_id"}]},
4514              {"entity_id":"cart_item","table":"cart_item","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"cart_id"},{"field_id":"user_id"},{"field_id":"product_id"},{"field_id":"price_minor"}]}
4515            ]}"#,
4516        );
4517        write(
4518            &root,
4519            "data/contracts/features.json",
4520            r#"{"features":[{"feature_id":"cart","queries":{
4521              "published_product":{"entity":"product","fields":["id","status","price_minor"],"filters":{"id":"input.product_id","status":"published"},"result":"one"},
4522              "current_cart":{"entity":"cart","fields":["id","user_id"],"filters":{"user_id":"subject.user_id"},"result":"one"},
4523              "current_cart_items":{"entity":"cart_item","fields":["id","user_id","product_id","price_minor"],"filters":{"user_id":"subject.user_id"},"result":"many","limit":20}
4524            }}]}"#,
4525        );
4526        write(
4527            &root,
4528            "data/contracts/actions.json",
4529            r#"{"actions":[{"action_id":"add_item","feature_id":"cart","input_contract":{"inline_fields":{"product_id":{"type":"integer","required":true,"minimum":1}},"reject_unknown":true},"transaction_contract":{"steps":[{"require":{"query":"published_product"}},{"require":{"query":"current_cart"}},{"upsert":{"entity":"cart_item","unique_by":["cart_id","product_id"],"values":{"cart_id":"query.current_cart.id","user_id":"subject.user_id","product_id":"input.product_id","price_minor":"query.published_product.price_minor"}}}]},"response_contract":{"response_kind":"fragment","template":"templates/fragments/cart_summary.html","target":"cart-panel","queries":["current_cart_items"],"fallback":{"redirect":"/"}},"guard_contract":{"guards":[{"guard_id":"demo_cart_total_minor_cap","kind":"sum","query":"current_cart_items","field":"price_minor","add":"query.published_product.price_minor","maximum":6000,"message":"Demo cart total is capped at $60 before checkout."}]}}]}"#,
4530        );
4531        write(
4532            &root,
4533            "templates/fragments/cart_summary.html",
4534            r#"<aside id="cart-panel">{current_cart_items_count}</aside>"#,
4535        );
4536        assert_eq!(
4537            initialize_logical_test_records(
4538                &root,
4539                &serde_json::json!([{"entity":"product","fields":{"id":1,"status":"published","price_minor":2900}},{"entity":"product","fields":{"id":2,"status":"published","price_minor":3400}},{"entity":"cart","fields":{"id":3,"user_id":42}},{"entity":"cart_item","fields":{"id":9,"cart_id":3,"user_id":42,"product_id":1,"price_minor":2900}}])
4540            ),
4541            0
4542        );
4543        let app = crate::config::AppRuntime {
4544            app_id: Some("feature_test".to_owned()),
4545            app_root: Some(root.to_string_lossy().into_owned()),
4546            artifact_dir: Some(root.to_string_lossy().into_owned()),
4547            ..crate::config::AppRuntime::default()
4548        };
4549        let route = serde_json::json!({
4550            "path":"/cart/items",
4551            "feature_id":"cart",
4552            "action_id":"add_item"
4553        });
4554        let request = super::super::http::HttpRequest {
4555            method: "POST".to_owned(),
4556            target: "/cart/items".to_owned(),
4557            content_type: Some("application/json".to_owned()),
4558            body: br#"{"product_id":2}"#.to_vec(),
4559            ..super::super::http::HttpRequest::default()
4560        };
4561        let error =
4562            execute_feature_action(&app, &route, &request, 42).expect_err("guard rejection");
4563        match error {
4564            FeatureRuntimeError::ValidationReport(report) => {
4565                assert!(report.contains("demo_cart_total_minor_cap"));
4566                assert!(report.contains("Demo cart total is capped at $60 before checkout."));
4567            }
4568            other => panic!("unexpected error: {other:?}"),
4569        }
4570        std::fs::remove_dir_all(root).expect("cleanup");
4571    }
4572
4573    #[cfg(all(feature = "sqlite", not(target_os = "wasi")))]
4574    #[test]
4575    /// Performs feature action reserve releases expired rows before capacity check.
4576    fn feature_action_reserve_releases_expired_rows_before_capacity_check() {
4577        let root = test_root("reserve-expiry-action");
4578        let database_path = root.join("feature.sqlite3");
4579        std::fs::create_dir_all(&root).expect("root");
4580        write(
4581            &root,
4582            "pharos.app.yml",
4583            "app_id: feature_test\nname: Feature Test\nversion: 1.0.0\ndatabase:\n  migration_contract: data/contracts/database.json\n",
4584        );
4585        write(
4586            &root,
4587            "app.conf",
4588            &format!("db_backend=sqlite\ndb_path={}\n", database_path.display()),
4589        );
4590        write(
4591            &root,
4592            "data/contracts/migrations.json",
4593            r#"{"state_cutover_mode":"relational_runtime_authority"}"#,
4594        );
4595        write(
4596            &root,
4597            "data/contracts/entities.json",
4598            r#"{"entities":[
4599              {"entity_id":"product","table":"product","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"status"},{"field_id":"slug"},{"field_id":"title"},{"field_id":"price_minor"}]},
4600              {"entity_id":"cart","table":"cart","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"user_id"}]},
4601              {"entity_id":"cart_item","table":"cart_item","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"cart_id"},{"field_id":"user_id"},{"field_id":"product_id"},{"field_id":"price_minor"},{"field_id":"added_at"}]},
4602              {"entity_id":"product_inventory","table":"product_inventory","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"product_id"},{"field_id":"available_quantity"}]},
4603              {"entity_id":"inventory_reservation","table":"inventory_reservation","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"cart_id"},{"field_id":"user_id"},{"field_id":"product_id"},{"field_id":"reservation_status"},{"field_id":"reserved_at"},{"field_id":"released_at"},{"field_id":"release_reason"}]}
4604            ]}"#,
4605        );
4606        write(
4607            &root,
4608            "data/contracts/features.json",
4609            r#"{"features":[{"feature_id":"cart","queries":{
4610              "published_product":{"entity":"product","fields":["id","status","slug","title","price_minor"],"filters":{"id":"input.product_id","status":"published"},"result":"one"},
4611              "current_cart":{"entity":"cart","fields":["id","user_id"],"filters":{"user_id":"subject.user_id"},"result":"one"},
4612              "current_cart_items":{"entity":"cart_item","fields":["id","user_id","product_id","price_minor","added_at"],"filters":{"user_id":"subject.user_id","added_at":{"value":{"server_now_minus_seconds":3600},"operator":"gt"}},"result":"many","limit":20},
4613              "product_inventory":{"entity":"product_inventory","fields":["id","product_id","available_quantity"],"filters":{"product_id":"input.product_id"},"result":"one"}
4614            }}]}"#,
4615        );
4616        write(
4617            &root,
4618            "data/contracts/actions.json",
4619            r#"{"actions":[{"action_id":"add_item","feature_id":"cart","input_contract":{"inline_fields":{"product_id":{"type":"integer","required":true,"minimum":1}},"reject_unknown":true},"transaction_contract":{"steps":[{"require":{"query":"published_product"}},{"require":{"query":"current_cart"}},{"require":{"query":"product_inventory"}},{"reserve":{"entity":"inventory_reservation","status_field":"reservation_status","active_statuses":["reserved","confirmed"],"expires_after_seconds":3600,"owner_filters":{"cart_id":"query.current_cart.id","product_id":"query.published_product.id"},"scope_filters":{"product_id":"query.published_product.id"},"capacity":"query.product_inventory.available_quantity","on_conflict":{"code":"inventory_reserved_out","message":"Inventory is fully reserved."},"values":{"cart_id":"query.current_cart.id","user_id":"subject.user_id","product_id":"query.published_product.id","reservation_status":"reserved","reserved_at":"server.now"}}},{"upsert":{"entity":"cart_item","unique_by":["cart_id","product_id"],"update_on_conflict":true,"values":{"cart_id":"query.current_cart.id","user_id":"subject.user_id","product_id":"query.published_product.id","price_minor":"query.published_product.price_minor","added_at":"server.now"}}}]},"response_contract":{"response_kind":"fragment","template":"templates/fragments/cart_summary.html","target":"cart-panel","queries":["current_cart_items"],"fallback":{"redirect":"/"}}}]}"#,
4620        );
4621        write(
4622            &root,
4623            "templates/fragments/cart_summary.html",
4624            r#"<aside id="cart-panel">{current_cart_items_count}</aside>"#,
4625        );
4626        assert_eq!(
4627            initialize_logical_test_records(
4628                &root,
4629                &serde_json::json!([{"entity":"product","fields":{"id":1,"status":"published","slug":"neon","title":"Neon","price_minor":2900}},{"entity":"cart","fields":{"id":3,"user_id":42}},{"entity":"cart","fields":{"id":5,"user_id":7}},{"entity":"cart_item","fields":{"id":4,"cart_id":3,"user_id":42,"product_id":1,"price_minor":2900,"added_at":"2026-08-30T00:00:00Z"}},{"entity":"product_inventory","fields":{"id":1,"product_id":1,"available_quantity":1}},{"entity":"inventory_reservation","fields":{"id":9,"cart_id":5,"user_id":7,"product_id":1,"reservation_status":"reserved","reserved_at":"2026-08-30T00:00:00Z","release_reason":""}},{"entity":"inventory_reservation","fields":{"id":10,"cart_id":3,"user_id":42,"product_id":1,"reservation_status":"reserved","reserved_at":"2026-08-30T00:00:00Z","release_reason":""}}])
4630            ),
4631            0
4632        );
4633        let app = crate::config::AppRuntime {
4634            app_id: Some("feature_test".to_owned()),
4635            app_root: Some(root.to_string_lossy().into_owned()),
4636            artifact_dir: Some(root.to_string_lossy().into_owned()),
4637            ..crate::config::AppRuntime::default()
4638        };
4639        let route = serde_json::json!({
4640            "path":"/cart/items",
4641            "feature_id":"cart",
4642            "action_id":"add_item"
4643        });
4644        let request = super::super::http::HttpRequest {
4645            method: "POST".to_owned(),
4646            target: "/cart/items".to_owned(),
4647            content_type: Some("application/json".to_owned()),
4648            body: br#"{"product_id":1}"#.to_vec(),
4649            ..super::super::http::HttpRequest::default()
4650        };
4651        let outcome = execute_feature_action(&app, &route, &request, 42).expect("reserve action");
4652        assert!(outcome.fragment_html.contains(r#"id="cart-panel""#));
4653        assert_eq!(
4654            logical_test_value(
4655                &root,
4656                "inventory_reservation",
4657                "reservation_status",
4658                Some(pharos_runtime_database::operations::Predicate::Equal(
4659                    "id".to_owned(),
4660                    serde_json::json!(9)
4661                ))
4662            )
4663            .as_deref(),
4664            Some("released")
4665        );
4666        assert_eq!(
4667            logical_test_value(
4668                &root,
4669                "inventory_reservation",
4670                "reservation_status",
4671                Some(pharos_runtime_database::operations::Predicate::Equal(
4672                    "id".to_owned(),
4673                    serde_json::json!(10)
4674                ))
4675            )
4676            .as_deref(),
4677            Some("released")
4678        );
4679        assert_eq!(
4680            logical_test_value(
4681                &root,
4682                "inventory_reservation",
4683                "release_reason",
4684                Some(pharos_runtime_database::operations::Predicate::Equal(
4685                    "id".to_owned(),
4686                    serde_json::json!(9)
4687                ))
4688            )
4689            .as_deref(),
4690            Some("reservation_expired")
4691        );
4692        assert_eq!(
4693            logical_test_value(
4694                &root,
4695                "inventory_reservation",
4696                "COUNT(*)",
4697                Some(pharos_runtime_database::operations::Predicate::Equal(
4698                    "reservation_status".to_owned(),
4699                    serde_json::json!("reserved")
4700                ))
4701            )
4702            .as_deref(),
4703            Some("1")
4704        );
4705        assert_ne!(
4706            logical_test_value(
4707                &root,
4708                "cart_item",
4709                "added_at",
4710                Some(pharos_runtime_database::operations::Predicate::Equal(
4711                    "id".to_owned(),
4712                    serde_json::json!(4)
4713                ))
4714            )
4715            .as_deref(),
4716            Some("2026-08-30T00:00:00Z")
4717        );
4718        std::fs::remove_dir_all(root).expect("cleanup");
4719    }
4720
4721    #[cfg(all(feature = "sqlite", not(target_os = "wasi")))]
4722    #[test]
4723    /// Performs feature action reserve returns structured conflict report.
4724    fn feature_action_reserve_returns_structured_conflict_report() {
4725        let root = test_root("reserve-action");
4726        let database_path = root.join("feature.sqlite3");
4727        std::fs::create_dir_all(&root).expect("root");
4728        write(
4729            &root,
4730            "pharos.app.yml",
4731            "app_id: feature_test\nname: Feature Test\nversion: 1.0.0\ndatabase:\n  migration_contract: data/contracts/database.json\n",
4732        );
4733        write(
4734            &root,
4735            "app.conf",
4736            &format!("db_backend=sqlite\ndb_path={}\n", database_path.display()),
4737        );
4738        write(
4739            &root,
4740            "data/contracts/migrations.json",
4741            r#"{"state_cutover_mode":"relational_runtime_authority"}"#,
4742        );
4743        write(
4744            &root,
4745            "data/contracts/entities.json",
4746            r#"{"entities":[
4747              {"entity_id":"product","table":"product","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"status"},{"field_id":"slug"},{"field_id":"title"},{"field_id":"price_minor"}]},
4748              {"entity_id":"cart","table":"cart","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"user_id"}]},
4749              {"entity_id":"cart_item","table":"cart_item","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"cart_id"},{"field_id":"user_id"},{"field_id":"product_id"},{"field_id":"price_minor"}]},
4750              {"entity_id":"product_inventory","table":"product_inventory","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"product_id"},{"field_id":"available_quantity"}]},
4751              {"entity_id":"inventory_reservation","table":"inventory_reservation","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"cart_id"},{"field_id":"user_id"},{"field_id":"product_id"},{"field_id":"reservation_status"},{"field_id":"reserved_at"},{"field_id":"released_at"},{"field_id":"release_reason"}]}
4752            ]}"#,
4753        );
4754        write(
4755            &root,
4756            "data/contracts/features.json",
4757            r#"{"features":[{"feature_id":"cart","queries":{
4758              "published_product":{"entity":"product","fields":["id","status","slug","title","price_minor"],"filters":{"id":"input.product_id","status":"published"},"result":"one"},
4759              "current_cart":{"entity":"cart","fields":["id","user_id"],"filters":{"user_id":"subject.user_id"},"result":"one"},
4760              "current_cart_items":{"entity":"cart_item","fields":["id","user_id","product_id","price_minor"],"filters":{"user_id":"subject.user_id"},"result":"many","limit":20},
4761              "product_inventory":{"entity":"product_inventory","fields":["id","product_id","available_quantity"],"filters":{"product_id":"input.product_id"},"result":"one"}
4762            }}]}"#,
4763        );
4764        write(
4765            &root,
4766            "data/contracts/actions.json",
4767            r#"{"actions":[{"action_id":"add_item","feature_id":"cart","input_contract":{"inline_fields":{"product_id":{"type":"integer","required":true,"minimum":1}},"reject_unknown":true},"transaction_contract":{"steps":[{"require":{"query":"published_product"}},{"require":{"query":"current_cart"}},{"require":{"query":"product_inventory"}},{"reserve":{"entity":"inventory_reservation","status_field":"reservation_status","active_statuses":["reserved","confirmed"],"owner_filters":{"cart_id":"query.current_cart.id","product_id":"query.published_product.id"},"scope_filters":{"product_id":"query.published_product.id"},"capacity":"query.product_inventory.available_quantity","on_conflict":{"code":"inventory_reserved_out","message":"Inventory is fully reserved."},"values":{"cart_id":"query.current_cart.id","user_id":"subject.user_id","product_id":"query.published_product.id","reservation_status":"reserved","reserved_at":"server.now"}}},{"insert":{"entity":"cart_item","values":{"cart_id":"query.current_cart.id","user_id":"subject.user_id","product_id":"query.published_product.id","price_minor":"query.published_product.price_minor"}}}]},"response_contract":{"response_kind":"fragment","template":"templates/fragments/cart_summary.html","target":"cart-panel","queries":["current_cart_items"],"fallback":{"redirect":"/"}}}]}"#,
4768        );
4769        write(
4770            &root,
4771            "templates/fragments/cart_summary.html",
4772            r#"<aside id="cart-panel">{current_cart_items_count}</aside>"#,
4773        );
4774        assert_eq!(
4775            initialize_logical_test_records(
4776                &root,
4777                &serde_json::json!([{"entity":"product","fields":{"id":1,"status":"published","slug":"neon","title":"Neon","price_minor":2900}},{"entity":"cart","fields":{"id":3,"user_id":42}},{"entity":"cart","fields":{"id":5,"user_id":7}},{"entity":"product_inventory","fields":{"id":1,"product_id":1,"available_quantity":1}},{"entity":"inventory_reservation","fields":{"id":9,"cart_id":5,"user_id":7,"product_id":1,"reservation_status":"reserved","reserved_at":"2026-08-30T00:00:00Z","release_reason":""}}])
4778            ),
4779            0
4780        );
4781        let app = crate::config::AppRuntime {
4782            app_id: Some("feature_test".to_owned()),
4783            app_root: Some(root.to_string_lossy().into_owned()),
4784            artifact_dir: Some(root.to_string_lossy().into_owned()),
4785            ..crate::config::AppRuntime::default()
4786        };
4787        let route = serde_json::json!({
4788            "path":"/cart/items",
4789            "feature_id":"cart",
4790            "action_id":"add_item"
4791        });
4792        let request = super::super::http::HttpRequest {
4793            method: "POST".to_owned(),
4794            target: "/cart/items".to_owned(),
4795            content_type: Some("application/json".to_owned()),
4796            body: br#"{"product_id":1}"#.to_vec(),
4797            ..super::super::http::HttpRequest::default()
4798        };
4799        let error =
4800            execute_feature_action(&app, &route, &request, 42).expect_err("reservation rejection");
4801        match error {
4802            FeatureRuntimeError::ValidationReport(report) => {
4803                assert!(report.contains("inventory_reserved_out"));
4804                assert!(report.contains("Inventory is fully reserved."));
4805            }
4806            other => panic!("unexpected error: {other:?}"),
4807        }
4808        std::fs::remove_dir_all(root).expect("cleanup");
4809    }
4810
4811    #[cfg(all(feature = "sqlite", not(target_os = "wasi")))]
4812    #[test]
4813    /// Performs feature action confirm transitions reserved row.
4814    fn feature_action_confirm_transitions_reserved_row() {
4815        let root = test_root("confirm-action");
4816        let database_path = root.join("feature.sqlite3");
4817        std::fs::create_dir_all(&root).expect("root");
4818        write(
4819            &root,
4820            "pharos.app.yml",
4821            "app_id: feature_test\nname: Feature Test\nversion: 1.0.0\ndatabase:\n  migration_contract: data/contracts/database.json\n",
4822        );
4823        write(
4824            &root,
4825            "app.conf",
4826            &format!("db_backend=sqlite\ndb_path={}\n", database_path.display()),
4827        );
4828        write(
4829            &root,
4830            "data/contracts/migrations.json",
4831            r#"{"state_cutover_mode":"relational_runtime_authority"}"#,
4832        );
4833        write(
4834            &root,
4835            "data/contracts/entities.json",
4836            r#"{"entities":[
4837              {"entity_id":"cart","table":"cart","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"user_id"}]},
4838              {"entity_id":"inventory_reservation","table":"inventory_reservation","identity_field":"id","fields":[{"field_id":"id"},{"field_id":"cart_id"},{"field_id":"user_id"},{"field_id":"product_id"},{"field_id":"reservation_status"},{"field_id":"reserved_at"},{"field_id":"confirmed_at"},{"field_id":"released_at"},{"field_id":"release_reason"}]}
4839            ]}"#,
4840        );
4841        write(
4842            &root,
4843            "data/contracts/features.json",
4844            r#"{"features":[{"feature_id":"cart","queries":{
4845              "current_cart":{"entity":"cart","fields":["id","user_id"],"filters":{"user_id":"subject.user_id"},"result":"one"}
4846            }}]}"#,
4847        );
4848        write(
4849            &root,
4850            "data/contracts/actions.json",
4851            r#"{"actions":[{"action_id":"confirm_item","feature_id":"cart","input_contract":{"inline_fields":{"product_id":{"type":"integer","required":true,"minimum":1}},"reject_unknown":true},"transaction_contract":{"steps":[{"require":{"query":"current_cart"}},{"confirm":{"entity":"inventory_reservation","status_field":"reservation_status","active_statuses":["reserved"],"owner_filters":{"cart_id":"query.current_cart.id","product_id":"input.product_id","user_id":"subject.user_id"},"scope_filters":{"product_id":"input.product_id"},"capacity":1,"to_status":"confirmed","values":{"confirmed_at":"server.now","release_reason":""}}}]},"response_contract":{"response_kind":"fragment","template":"templates/fragments/cart_summary.html","target":"cart-panel","queries":[],"fallback":{"redirect":"/"}}}]}"#,
4852        );
4853        write(
4854            &root,
4855            "templates/fragments/cart_summary.html",
4856            r#"<aside id="cart-panel">ok</aside>"#,
4857        );
4858        assert_eq!(
4859            initialize_logical_test_records(
4860                &root,
4861                &serde_json::json!([{"entity":"cart","fields":{"id":3,"user_id":42}},{"entity":"inventory_reservation","fields":{"id":9,"cart_id":3,"user_id":42,"product_id":1,"reservation_status":"reserved","reserved_at":"2026-08-30T00:00:00Z","release_reason":""}}])
4862            ),
4863            0
4864        );
4865        let app = crate::config::AppRuntime {
4866            app_id: Some("feature_test".to_owned()),
4867            app_root: Some(root.to_string_lossy().into_owned()),
4868            artifact_dir: Some(root.to_string_lossy().into_owned()),
4869            ..crate::config::AppRuntime::default()
4870        };
4871        let route = serde_json::json!({
4872            "path":"/cart/confirm",
4873            "feature_id":"cart",
4874            "action_id":"confirm_item"
4875        });
4876        let request = super::super::http::HttpRequest {
4877            method: "POST".to_owned(),
4878            target: "/cart/confirm".to_owned(),
4879            content_type: Some("application/json".to_owned()),
4880            body: br#"{"product_id":1}"#.to_vec(),
4881            ..super::super::http::HttpRequest::default()
4882        };
4883        let outcome = execute_feature_action(&app, &route, &request, 42).expect("confirm action");
4884        assert!(outcome.fragment_html.contains("ok"));
4885        assert_eq!(
4886            logical_test_value(
4887                &root,
4888                "inventory_reservation",
4889                "reservation_status",
4890                Some(pharos_runtime_database::operations::Predicate::Equal(
4891                    "id".to_owned(),
4892                    serde_json::json!(9)
4893                ))
4894            )
4895            .as_deref(),
4896            Some("confirmed")
4897        );
4898        std::fs::remove_dir_all(root).expect("cleanup");
4899    }
4900}