Skip to main content

pharos_runtime_core/
application_ir.rs

1//! Canonical typed application contract consumed by the native runtime.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use serde_json::Value;
8use sha2::{Digest, Sha256};
9/// Schema identifier emitted for compiled application IR documents.
10///
11/// # Examples
12///
13/// ```rust,ignore
14/// let _ = pharos_runtime_core::application_ir::APPLICATION_IR_VERSION;
15/// ```
16///
17/// # Related
18///
19/// - [`crate::application_ir`] for the surrounding module surface.
20pub const APPLICATION_IR_VERSION: &str = "pharos.application-ir.v1";
21/// Declarative contract version accepted by this runtime.
22///
23/// # Examples
24///
25/// ```rust,ignore
26/// let _ = pharos_runtime_core::application_ir::CONTRACT_VERSION;
27/// ```
28///
29/// # Related
30///
31/// - [`crate::application_ir`] for the surrounding module surface.
32pub const CONTRACT_VERSION: &str = "1.0";
33
34#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
35/// Stable identifier used for routes, actions, policies, and other cross-linked declarations.
36///
37/// # Fields
38///
39/// - This type stores module-owned state for [`crate::application_ir::ContractId`].
40///
41/// # Examples
42///
43/// ```rust,ignore
44/// let _ = std::mem::size_of::<pharos_runtime_core::application_ir::ContractId>();
45/// ```
46///
47/// # Related
48///
49/// - [`crate::application_ir`] for constructors, helpers, and neighboring runtime types.
50pub struct ContractId(String);
51
52impl ContractId {
53    /// Parses one validated contract identifier.
54    ///
55    /// # Parameters
56    ///
57    /// - `value`: Input consumed by [`crate::application_ir::parse`].
58    ///
59    /// # Returns
60    ///
61    /// Returns the successful value produced by [`crate::application_ir::parse`] or an error when the operation fails.
62    ///
63    /// # Errors
64    ///
65    /// Returns an error value when [`crate::application_ir::parse`] cannot complete successfully.
66    ///
67    /// # Performance
68    ///
69    /// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::application_ir::parse`].
70    ///
71    /// # Examples
72    ///
73    /// ```rust,ignore
74    /// let _result = pharos_runtime_core::application_ir::parse("value");
75    /// ```
76    ///
77    /// # Related
78    ///
79    /// - [`crate::application_ir`] for adjacent helpers that share the same runtime surface.
80    pub fn parse(value: &str) -> Result<Self, ApplicationIrError> {
81        if value.is_empty()
82            || value.len() > 128
83            || !value
84                .bytes()
85                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
86        {
87            return Err(ApplicationIrError::InvalidId(value.to_owned()));
88        }
89        Ok(Self(value.to_owned()))
90    }
91    /// Returns the canonical identifier text.
92    ///
93    /// # Returns
94    ///
95    /// Returns the computed value produced by [`crate::application_ir::as_str`].
96    ///
97    /// # Examples
98    ///
99    /// ```rust,ignore
100    /// let _result = pharos_runtime_core::application_ir::as_str();
101    /// ```
102    ///
103    /// # Related
104    ///
105    /// - [`crate::application_ir`] for adjacent helpers that share the same runtime surface.
106    pub fn as_str(&self) -> &str {
107        &self.0
108    }
109}
110
111#[derive(Debug, Clone, Eq, PartialEq)]
112/// Exact source location preserved for a compiled declaration.
113///
114/// # Fields
115///
116/// - `path`: Stored field on `SourceProvenance`.
117/// - `pointer`: Stored field on `SourceProvenance`.
118/// - `line`: Stored field on `SourceProvenance`.
119/// - `column`: Stored field on `SourceProvenance`.
120///
121/// # Examples
122///
123/// ```rust,ignore
124/// let _ = std::mem::size_of::<pharos_runtime_core::application_ir::SourceProvenance>();
125/// ```
126///
127/// # Related
128///
129/// - [`crate::application_ir`] for constructors, helpers, and neighboring runtime types.
130pub struct SourceProvenance {
131    pub path: PathBuf,
132    pub pointer: String,
133    pub line: usize,
134    pub column: usize,
135}
136
137#[derive(Debug, Clone, Eq, PartialEq)]
138/// Effective route security settings after contract compilation.
139///
140/// # Fields
141///
142/// - `csrf_mode`: Stored field on `RouteSecurityPosture`.
143/// - `trusted_origin_policy`: Stored field on `RouteSecurityPosture`.
144/// - `tenant_scope`: Stored field on `RouteSecurityPosture`.
145/// - `rate_limit_policy`: Stored field on `RouteSecurityPosture`.
146/// - `capability_policy`: Stored field on `RouteSecurityPosture`.
147///
148/// # Examples
149///
150/// ```rust,ignore
151/// let _ = std::mem::size_of::<pharos_runtime_core::application_ir::RouteSecurityPosture>();
152/// ```
153///
154/// # Related
155///
156/// - [`crate::application_ir`] for constructors, helpers, and neighboring runtime types.
157pub struct RouteSecurityPosture {
158    pub csrf_mode: String,
159    pub trusted_origin_policy: String,
160    pub tenant_scope: String,
161    pub rate_limit_policy: String,
162    pub capability_policy: String,
163}
164
165#[derive(Debug, Clone, Eq, PartialEq)]
166/// Bounded side effects declared for one action.
167///
168/// # Fields
169///
170/// - `reads`: Stored field on `ActionEffects`.
171/// - `writes`: Stored field on `ActionEffects`.
172/// - `emits`: Stored field on `ActionEffects`.
173/// - `enqueues`: Stored field on `ActionEffects`.
174/// - `external_calls`: Stored field on `ActionEffects`.
175/// - `cross_tenant`: Stored field on `ActionEffects`.
176///
177/// # Examples
178///
179/// ```rust,ignore
180/// let _ = std::mem::size_of::<pharos_runtime_core::application_ir::ActionEffects>();
181/// ```
182///
183/// # Related
184///
185/// - [`crate::application_ir`] for constructors, helpers, and neighboring runtime types.
186pub struct ActionEffects {
187    pub reads: BTreeSet<String>,
188    pub writes: BTreeSet<String>,
189    pub emits: BTreeSet<String>,
190    pub enqueues: BTreeSet<String>,
191    pub external_calls: BTreeSet<String>,
192    pub cross_tenant: bool,
193}
194
195#[derive(Debug, Clone, Eq, PartialEq)]
196/// Compiled action declaration with validated identifiers and effect boundaries.
197///
198/// # Fields
199///
200/// - `id`: Stored field on `ActionIr`.
201/// - `entity_id`: Stored field on `ActionIr`.
202/// - `policy_id`: Stored field on `ActionIr`.
203/// - `effects`: Stored field on `ActionIr`.
204/// - `source`: Stored field on `ActionIr`.
205///
206/// # Examples
207///
208/// ```rust,ignore
209/// let _ = std::mem::size_of::<pharos_runtime_core::application_ir::ActionIr>();
210/// ```
211///
212/// # Related
213///
214/// - [`crate::application_ir`] for constructors, helpers, and neighboring runtime types.
215pub struct ActionIr {
216    pub id: ContractId,
217    pub entity_id: Option<ContractId>,
218    pub policy_id: Option<ContractId>,
219    pub effects: ActionEffects,
220    pub source: SourceProvenance,
221}
222
223#[derive(Debug, Clone, Eq, PartialEq)]
224/// Compiled route declaration with validated target references and security posture.
225///
226/// # Fields
227///
228/// - `id`: Stored field on `RouteIr`.
229/// - `path`: Stored field on `RouteIr`.
230/// - `kind`: Stored field on `RouteIr`.
231/// - `method`: Stored field on `RouteIr`.
232/// - `action_id`: Stored field on `RouteIr`.
233/// - `page_id`: Stored field on `RouteIr`.
234/// - `policy_id`: Stored field on `RouteIr`.
235/// - `auth_gate`: Stored field on `RouteIr`.
236/// - `security`: Stored field on `RouteIr`.
237/// - `source`: Stored field on `RouteIr`.
238/// - `compatibility_view`: Stored field on `RouteIr`.
239///
240/// # Examples
241///
242/// ```rust,ignore
243/// let _ = std::mem::size_of::<pharos_runtime_core::application_ir::RouteIr>();
244/// ```
245///
246/// # Related
247///
248/// - [`crate::application_ir`] for constructors, helpers, and neighboring runtime types.
249pub struct RouteIr {
250    pub id: ContractId,
251    pub path: String,
252    pub kind: String,
253    pub method: String,
254    pub action_id: Option<ContractId>,
255    pub page_id: Option<ContractId>,
256    pub policy_id: Option<ContractId>,
257    pub auth_gate: String,
258    pub security: RouteSecurityPosture,
259    pub source: SourceProvenance,
260    pub compatibility_view: Value,
261}
262
263#[derive(Debug, Clone, Eq, PartialEq)]
264/// Canonical in-memory application contract consumed by the runtime.
265///
266/// # Fields
267///
268/// - `version`: Stored field on `ApplicationIr`.
269/// - `contract_digest`: Stored field on `ApplicationIr`.
270/// - `app_id`: Stored field on `ApplicationIr`.
271/// - `routes`: Stored field on `ApplicationIr`.
272/// - `route_buckets`: Stored field on `ApplicationIr`.
273/// - `actions`: Stored field on `ApplicationIr`.
274/// - `entities`: Stored field on `ApplicationIr`.
275/// - `forms`: Stored field on `ApplicationIr`.
276/// - `policies`: Stored field on `ApplicationIr`.
277/// - `role_capability_edges`: Stored field on `ApplicationIr`.
278/// - `workflows`: Stored field on `ApplicationIr`.
279/// - `integrations`: Stored field on `ApplicationIr`.
280///
281/// # Examples
282///
283/// ```rust,ignore
284/// let _ = std::mem::size_of::<pharos_runtime_core::application_ir::ApplicationIr>();
285/// ```
286///
287/// # Related
288///
289/// - [`crate::application_ir`] for constructors, helpers, and neighboring runtime types.
290pub struct ApplicationIr {
291    pub version: &'static str,
292    pub contract_digest: String,
293    pub app_id: ContractId,
294    pub routes: BTreeMap<ContractId, RouteIr>,
295    pub route_buckets: BTreeMap<(String, usize), Vec<ContractId>>,
296    pub actions: BTreeMap<ContractId, ActionIr>,
297    pub entities: BTreeSet<ContractId>,
298    pub forms: BTreeSet<ContractId>,
299    pub policies: BTreeSet<ContractId>,
300    pub role_capability_edges: BTreeSet<(ContractId, ContractId)>,
301    pub workflows: BTreeSet<ContractId>,
302    pub integrations: BTreeSet<ContractId>,
303}
304
305/// Hashes the complete executable contract set in stable document order.
306fn contract_digest(documents: &[(&str, &Value)]) -> Result<String, ApplicationIrError> {
307    let mut digest = Sha256::new();
308    for (name, document) in documents {
309        digest.update(name.as_bytes());
310        digest.update([0]);
311        digest.update(
312            serde_json::to_vec(document)
313                .map_err(|_| ApplicationIrError::InvalidDocument((*name).to_owned()))?,
314        );
315        digest.update([0]);
316    }
317    Ok(digest
318        .finalize()
319        .iter()
320        .map(|byte| format!("{byte:02x}"))
321        .collect())
322}
323
324#[derive(Debug, Clone, Eq, PartialEq)]
325/// Semantic failures that block contract compilation or loading.
326///
327/// # Variants
328///
329/// - `MissingDocument`: Supported `ApplicationIrError` variant.
330/// - `InvalidDocument`: Supported `ApplicationIrError` variant.
331/// - `InvalidId`: Supported `ApplicationIrError` variant.
332/// - `DuplicateId`: Supported `ApplicationIrError` variant.
333/// - `MissingDeclaration`: Supported `ApplicationIrError` variant.
334/// - `UnsupportedVersion`: Supported `ApplicationIrError` variant.
335/// - `MissingReference`: Supported `ApplicationIrError` variant.
336///
337/// # Examples
338///
339/// ```rust,ignore
340/// let _ = pharos_runtime_core::application_ir::ApplicationIrError::MissingDocument;
341/// ```
342///
343/// # Related
344///
345/// - [`crate::application_ir`] for constructors, helpers, and neighboring runtime types.
346pub enum ApplicationIrError {
347    MissingDocument(String),
348    InvalidDocument(String),
349    InvalidId(String),
350    DuplicateId(String),
351    MissingDeclaration(String),
352    UnsupportedVersion(String),
353    MissingReference { owner: String, target: String },
354}
355
356/// Validates the exact major/minor contract version supported by this runtime.
357fn validate_contract_version(document: &Value, name: &str) -> Result<(), ApplicationIrError> {
358    match document.get("contract_version").and_then(Value::as_str) {
359        Some(CONTRACT_VERSION) => Ok(()),
360        Some(version) => Err(ApplicationIrError::UnsupportedVersion(format!(
361            "{name}:{version}"
362        ))),
363        None => Err(ApplicationIrError::MissingDeclaration(format!(
364            "{name}.contract_version"
365        ))),
366    }
367}
368
369/// Validates a populated optional contract while allowing an absent empty compatibility view.
370fn validate_optional_contract_version(
371    document: &Value,
372    array_key: &str,
373    name: &str,
374) -> Result<(), ApplicationIrError> {
375    if document
376        .get(array_key)
377        .and_then(Value::as_array)
378        .is_some_and(|rows| !rows.is_empty())
379    {
380        validate_contract_version(document, name)?;
381    }
382    Ok(())
383}
384
385/// Resolves and validates the route-level security defaults and overrides.
386fn route_security_posture(
387    route_doc: &Value,
388    row: &Value,
389    route_id: &str,
390) -> Result<RouteSecurityPosture, ApplicationIrError> {
391    let defaults = route_doc
392        .get("security_defaults")
393        .and_then(Value::as_object)
394        .ok_or_else(|| {
395            ApplicationIrError::MissingDeclaration("routes.security_defaults".to_owned())
396        })?;
397    let overrides = row.get("security").and_then(Value::as_object);
398    let field = |name: &str| {
399        overrides
400            .and_then(|values| values.get(name))
401            .or_else(|| defaults.get(name))
402            .and_then(Value::as_str)
403            .filter(|value| !value.is_empty())
404            .map(ToOwned::to_owned)
405            .ok_or_else(|| {
406                ApplicationIrError::MissingDeclaration(format!("routes.{route_id}.security.{name}"))
407            })
408    };
409    let posture = RouteSecurityPosture {
410        csrf_mode: field("csrf_mode")?,
411        trusted_origin_policy: field("trusted_origin_policy")?,
412        tenant_scope: field("tenant_scope")?,
413        rate_limit_policy: field("rate_limit_policy")?,
414        capability_policy: field("capability_policy")?,
415    };
416    if !matches!(
417        posture.csrf_mode.as_str(),
418        "mutation_required" | "required" | "disabled"
419    ) || !matches!(
420        posture.trusted_origin_policy.as_str(),
421        "mutation_required" | "required" | "disabled"
422    ) || !matches!(posture.tenant_scope.as_str(), "policy_bound" | "public")
423        || posture.rate_limit_policy.len() > 128
424        || !matches!(posture.capability_policy.as_str(), "policy_gate" | "public")
425    {
426        return Err(ApplicationIrError::InvalidDocument(format!(
427            "routes.{route_id}.security"
428        )));
429    }
430    Ok(posture)
431}
432
433/// Collects string values from one optional array without permitting empty effects.
434fn effect_strings(value: Option<&Value>) -> Result<BTreeSet<String>, ApplicationIrError> {
435    let mut values = BTreeSet::new();
436    for item in value.and_then(Value::as_array).into_iter().flatten() {
437        let text = item
438            .as_str()
439            .filter(|text| !text.is_empty() && text.len() <= 128)
440            .ok_or_else(|| ApplicationIrError::InvalidDocument("action effects".to_owned()))?;
441        values.insert(text.to_owned());
442    }
443    Ok(values)
444}
445
446/// Collects scheduled-job identifiers from string or bounded object declarations.
447fn effect_job_strings(value: Option<&Value>) -> Result<BTreeSet<String>, ApplicationIrError> {
448    let mut values = BTreeSet::new();
449    for item in value.and_then(Value::as_array).into_iter().flatten() {
450        let text = item
451            .as_str()
452            .or_else(|| item.get("job_id").and_then(Value::as_str))
453            .filter(|text| !text.is_empty() && text.len() <= 128)
454            .ok_or_else(|| ApplicationIrError::InvalidDocument("action effects".to_owned()))?;
455        values.insert(text.to_owned());
456    }
457    Ok(values)
458}
459
460/// Compiles the existing action declaration into a closed typed effect set.
461///
462/// # Parameters
463///
464/// - `action`: Input consumed by [`crate::application_ir::compile_action_effects_for_runtime`].
465/// - `transition`: Input consumed by [`crate::application_ir::compile_action_effects_for_runtime`].
466///
467/// # Returns
468///
469/// Returns the successful value produced by [`crate::application_ir::compile_action_effects_for_runtime`] or an error when the operation fails.
470///
471/// # Errors
472///
473/// Returns an error value when [`crate::application_ir::compile_action_effects_for_runtime`] cannot complete successfully.
474///
475/// # Performance
476///
477/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::application_ir::compile_action_effects_for_runtime`].
478///
479/// # Examples
480///
481/// ```rust,ignore
482/// let _result = pharos_runtime_core::application_ir::compile_action_effects_for_runtime(todo!(), todo!());
483/// ```
484///
485/// # Related
486///
487/// - [`crate::application_ir`] for adjacent helpers that share the same runtime surface.
488pub fn compile_action_effects_for_runtime(
489    action: &Value,
490    transition: Option<&Value>,
491) -> Result<ActionEffects, ApplicationIrError> {
492    let mut reads = BTreeSet::new();
493    let mut writes = BTreeSet::new();
494    reads.extend(effect_strings(
495        action
496            .get("effect_contract")
497            .and_then(|value| value.get("reads")),
498    )?);
499    writes.extend(effect_strings(
500        action
501            .get("effect_contract")
502            .and_then(|value| value.get("writes")),
503    )?);
504    if let Some(entity) = action.get("entity_id").and_then(Value::as_str) {
505        reads.insert(format!("entity:{entity}"));
506        writes.insert(format!("entity:{entity}"));
507    }
508    for primitive in action
509        .get("mutation_primitives")
510        .and_then(Value::as_array)
511        .into_iter()
512        .flatten()
513    {
514        if let Some(collection) = primitive.get("collection").and_then(Value::as_str) {
515            reads.insert(format!("state:{collection}"));
516            writes.insert(format!("state:{collection}"));
517        }
518        if primitive.get("primitive_kind").and_then(Value::as_str) == Some("set_root_fields") {
519            writes.insert("state:root".to_owned());
520        }
521    }
522    let mut emits = BTreeSet::new();
523    if let Some(event) = action.get("emits_event").and_then(Value::as_str) {
524        emits.insert(event.to_owned());
525    }
526    if let Some(event) = transition
527        .and_then(|value| value.get("emits_event"))
528        .and_then(Value::as_str)
529    {
530        emits.insert(event.to_owned());
531    }
532    let hooks = action.get("effect_hooks");
533    let mut enqueues = BTreeSet::new();
534    for source in [
535        action.get("scheduled_jobs"),
536        hooks.and_then(|value| value.get("scheduled_jobs")),
537        transition.and_then(|value| value.get("scheduled_jobs")),
538    ] {
539        enqueues.extend(effect_job_strings(source)?);
540    }
541    for notification in hooks
542        .and_then(|value| value.get("notifications"))
543        .and_then(Value::as_array)
544        .into_iter()
545        .flatten()
546    {
547        let adapter = notification
548            .get("adapter_id")
549            .and_then(Value::as_str)
550            .filter(|value| !value.is_empty())
551            .ok_or_else(|| ApplicationIrError::InvalidDocument("action effects".to_owned()))?;
552        enqueues.insert(format!("notification:{adapter}"));
553    }
554    for audit in hooks
555        .and_then(|value| value.get("audit_records"))
556        .and_then(Value::as_array)
557        .into_iter()
558        .flatten()
559    {
560        let adapter = audit
561            .get("adapter_id")
562            .and_then(Value::as_str)
563            .filter(|value| !value.is_empty())
564            .ok_or_else(|| ApplicationIrError::InvalidDocument("action effects".to_owned()))?;
565        writes.insert("state:audit_log".to_owned());
566        writes.insert("state:action_reports".to_owned());
567        reads.insert(format!("integration:{adapter}"));
568    }
569    let mut external_calls = effect_strings(hooks.and_then(|value| value.get("webhook_dispatch")))?;
570    for file_record in hooks
571        .and_then(|value| value.get("file_records"))
572        .and_then(Value::as_array)
573        .into_iter()
574        .flatten()
575    {
576        let adapter = file_record
577            .get("adapter_id")
578            .and_then(Value::as_str)
579            .filter(|value| !value.is_empty())
580            .ok_or_else(|| ApplicationIrError::InvalidDocument("action effects".to_owned()))?;
581        reads.insert(format!("integration:{adapter}"));
582        external_calls.insert(format!("file:{adapter}"));
583    }
584    if !action
585        .get("mutation_primitives")
586        .and_then(Value::as_array)
587        .is_none_or(Vec::is_empty)
588    {
589        for section in [
590            "workflow_executions",
591            "workflow_execution_events",
592            "action_reports",
593        ] {
594            writes.insert(format!("state:{section}"));
595        }
596        writes.insert("state:root".to_owned());
597    }
598    Ok(ActionEffects {
599        reads,
600        writes,
601        emits,
602        enqueues,
603        external_calls,
604        cross_tenant: action
605            .get("cross_tenant")
606            .and_then(Value::as_bool)
607            .unwrap_or(false),
608    })
609}
610
611/// Returns the workflow transition executed by one action, rejecting ambiguous declarations.
612fn action_transition<'a>(
613    workflow_doc: &'a Value,
614    action_id: &str,
615) -> Result<Option<&'a Value>, ApplicationIrError> {
616    let matches = workflow_doc
617        .get("workflows")
618        .and_then(Value::as_array)
619        .into_iter()
620        .flatten()
621        .flat_map(|workflow| {
622            workflow
623                .get("transitions")
624                .and_then(Value::as_array)
625                .into_iter()
626                .flatten()
627        })
628        .filter(|transition| transition.get("action_id").and_then(Value::as_str) == Some(action_id))
629        .collect::<Vec<_>>();
630    match matches.as_slice() {
631        [] => Ok(None),
632        [transition] => Ok(Some(*transition)),
633        _ => Err(ApplicationIrError::InvalidDocument(format!(
634            "ambiguous action transition: {action_id}"
635        ))),
636    }
637}
638
639/// Loads a JSON contract document from a packaged application artifact.
640fn load_contract(artifact_root: &Path, name: &str) -> Result<(PathBuf, Value), ApplicationIrError> {
641    let path = artifact_root.join(format!("data/contracts/{name}.json"));
642    let text = fs::read_to_string(&path)
643        .map_err(|_| ApplicationIrError::MissingDocument(name.to_owned()))?;
644    let value = serde_json::from_str(&text)
645        .map_err(|_| ApplicationIrError::InvalidDocument(name.to_owned()))?;
646    Ok((path, value))
647}
648
649/// Loads an optional contract document as an empty typed collection when undeclared.
650fn load_optional_contract(
651    artifact_root: &Path,
652    name: &str,
653    array_key: &str,
654) -> Result<(PathBuf, Value), ApplicationIrError> {
655    match load_contract(artifact_root, name) {
656        Ok(document) => Ok(document),
657        Err(ApplicationIrError::MissingDocument(_)) => Ok((
658            artifact_root.join(format!("data/contracts/{name}.json")),
659            serde_json::json!({array_key: []}),
660        )),
661        Err(error) => Err(error),
662    }
663}
664
665/// Collects a canonically ordered identifier set from one contract array.
666fn collect_ids(
667    document: &Value,
668    array_key: &str,
669    id_key: &str,
670) -> Result<BTreeSet<ContractId>, ApplicationIrError> {
671    let mut ids = BTreeSet::new();
672    let rows = document
673        .get(array_key)
674        .and_then(Value::as_array)
675        .ok_or_else(|| ApplicationIrError::InvalidDocument(array_key.to_owned()))?;
676    for row in rows {
677        let raw = row
678            .get(id_key)
679            .and_then(Value::as_str)
680            .ok_or_else(|| ApplicationIrError::InvalidDocument(array_key.to_owned()))?;
681        let id = ContractId::parse(raw)?;
682        if !ids.insert(id) {
683            return Err(ApplicationIrError::DuplicateId(raw.to_owned()));
684        }
685    }
686    Ok(ids)
687}
688
689/// Collects a canonically ordered identifier set from a string array.
690fn collect_string_ids(
691    document: &Value,
692    array_key: &str,
693) -> Result<BTreeSet<ContractId>, ApplicationIrError> {
694    let mut ids = BTreeSet::new();
695    for value in document
696        .get(array_key)
697        .and_then(Value::as_array)
698        .ok_or_else(|| ApplicationIrError::InvalidDocument(array_key.to_owned()))?
699    {
700        let raw = value
701            .as_str()
702            .ok_or_else(|| ApplicationIrError::InvalidDocument(array_key.to_owned()))?;
703        let id = ContractId::parse(raw)?;
704        if !ids.insert(id) {
705            return Err(ApplicationIrError::DuplicateId(raw.to_owned()));
706        }
707    }
708    Ok(ids)
709}
710
711/// Returns a source line for an identifier in a deterministic generated JSON document.
712fn source_line(path: &Path, id_key: &str, id: &str) -> usize {
713    let needle = format!("\"{id_key}\": \"{id}\"");
714    fs::read_to_string(path)
715        .ok()
716        .and_then(|text| text.lines().position(|line| line.contains(&needle)))
717        .map(|line| line + 1)
718        .unwrap_or(1)
719}
720
721/// Resolves generator-retained YAML provenance with a JSON fallback for legacy artifacts.
722fn source_provenance(
723    document: &Value,
724    generated_path: &Path,
725    pointer: String,
726    id_key: &str,
727    id: &str,
728) -> SourceProvenance {
729    let source = document.get("__pharos_source");
730    let location = source
731        .and_then(|value| value.get("locations"))
732        .and_then(|value| value.get(format!("{id_key}:{id}")));
733    SourceProvenance {
734        path: source
735            .and_then(|value| value.get("path"))
736            .and_then(Value::as_str)
737            .map(PathBuf::from)
738            .unwrap_or_else(|| generated_path.to_path_buf()),
739        pointer,
740        line: location
741            .and_then(|value| value.get("line"))
742            .and_then(Value::as_u64)
743            .and_then(|line| usize::try_from(line).ok())
744            .unwrap_or_else(|| source_line(generated_path, id_key, id)),
745        column: location
746            .and_then(|value| value.get("column"))
747            .and_then(Value::as_u64)
748            .and_then(|column| usize::try_from(column).ok())
749            .unwrap_or(1),
750    }
751}
752
753/// Validates one optional identifier reference against its typed target collection.
754fn validate_reference(
755    owner: &str,
756    row: &Value,
757    key: &str,
758    targets: &BTreeSet<ContractId>,
759) -> Result<(), ApplicationIrError> {
760    let Some(raw) = row.get(key).and_then(Value::as_str) else {
761        return Ok(());
762    };
763    let id = ContractId::parse(raw)?;
764    if targets.contains(&id) {
765        Ok(())
766    } else {
767        Err(ApplicationIrError::MissingReference {
768            owner: owner.to_owned(),
769            target: raw.to_owned(),
770        })
771    }
772}
773
774/// Compiles and semantically validates all executable application contracts.
775///
776/// # Parameters
777///
778/// - `artifact_root`: Input consumed by [`crate::application_ir::compile_application_ir`].
779/// - `manifest`: Input consumed by [`crate::application_ir::compile_application_ir`].
780///
781/// # Returns
782///
783/// Returns the successful value produced by [`crate::application_ir::compile_application_ir`] or an error when the operation fails.
784///
785/// # Errors
786///
787/// Returns an error value when [`crate::application_ir::compile_application_ir`] cannot complete successfully.
788///
789/// # Performance
790///
791/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::application_ir::compile_application_ir`].
792///
793/// # Examples
794///
795/// ```rust,ignore
796/// let _result = pharos_runtime_core::application_ir::compile_application_ir(todo!(), todo!());
797/// ```
798///
799/// # Related
800///
801/// - [`crate::application_ir`] for adjacent helpers that share the same runtime surface.
802pub fn compile_application_ir(
803    artifact_root: &Path,
804    manifest: &Value,
805) -> Result<ApplicationIr, ApplicationIrError> {
806    let app_id = ContractId::parse(
807        manifest
808            .get("app_id")
809            .and_then(Value::as_str)
810            .ok_or_else(|| ApplicationIrError::InvalidDocument("manifest".to_owned()))?,
811    )?;
812    let (route_path, route_doc) = load_contract(artifact_root, "routes")?;
813    let (action_path, action_doc) = load_optional_contract(artifact_root, "actions", "actions")?;
814    let (_, entity_doc) = load_optional_contract(artifact_root, "entities", "entities")?;
815    let (_, form_doc) = load_optional_contract(artifact_root, "forms", "forms")?;
816    let (_, policy_doc) = load_optional_contract(artifact_root, "policies", "capabilities")?;
817    let (_, workflow_doc) = load_optional_contract(artifact_root, "workflows", "workflows")?;
818    let (_, integration_doc) = load_optional_contract(artifact_root, "integrations", "adapters")?;
819    validate_contract_version(&route_doc, "routes")?;
820    validate_optional_contract_version(&entity_doc, "entities", "entities")?;
821    validate_optional_contract_version(&form_doc, "forms", "forms")?;
822    validate_optional_contract_version(&policy_doc, "capabilities", "policies")?;
823    validate_optional_contract_version(&workflow_doc, "workflows", "workflows")?;
824    validate_optional_contract_version(&integration_doc, "adapters", "integrations")?;
825    let action_rows = action_doc
826        .get("actions")
827        .and_then(Value::as_array)
828        .ok_or_else(|| ApplicationIrError::InvalidDocument("actions".to_owned()))?;
829    if !action_rows.is_empty() {
830        validate_contract_version(&action_doc, "actions")?;
831        if action_doc
832            .get("effect_policy")
833            .and_then(|value| value.get("deny_undeclared"))
834            .and_then(Value::as_bool)
835            != Some(true)
836        {
837            return Err(ApplicationIrError::MissingDeclaration(
838                "actions.effect_policy.deny_undeclared".to_owned(),
839            ));
840        }
841    }
842    let mut actions = BTreeMap::new();
843    for (index, action) in action_rows.iter().enumerate() {
844        let raw_id = action
845            .get("action_id")
846            .and_then(Value::as_str)
847            .ok_or_else(|| ApplicationIrError::InvalidDocument("actions".to_owned()))?;
848        let id = ContractId::parse(raw_id)?;
849        let entity_id = action
850            .get("entity_id")
851            .and_then(Value::as_str)
852            .map(ContractId::parse)
853            .transpose()?;
854        let policy_id = action
855            .get("policy_gate")
856            .and_then(Value::as_str)
857            .map(ContractId::parse)
858            .transpose()?;
859        let record = ActionIr {
860            id: id.clone(),
861            entity_id,
862            policy_id,
863            effects: compile_action_effects_for_runtime(
864                action,
865                action_transition(&workflow_doc, raw_id)?,
866            )?,
867            source: source_provenance(
868                &action_doc,
869                &action_path,
870                format!("/actions/{index}"),
871                "action_id",
872                raw_id,
873            ),
874        };
875        if actions.insert(id, record).is_some() {
876            return Err(ApplicationIrError::DuplicateId(raw_id.to_owned()));
877        }
878    }
879    for intrinsic in [
880        "login",
881        "login_account",
882        "logout",
883        "password_reset_request",
884        "request_password_reset",
885        "password_reset_complete",
886        "complete_password_reset",
887        "delete_appointment",
888    ] {
889        let id = ContractId::parse(intrinsic)?;
890        actions.entry(id.clone()).or_insert_with(|| ActionIr {
891            id,
892            entity_id: None,
893            policy_id: None,
894            effects: ActionEffects {
895                reads: BTreeSet::new(),
896                writes: BTreeSet::new(),
897                emits: BTreeSet::new(),
898                enqueues: BTreeSet::new(),
899                external_calls: BTreeSet::new(),
900                cross_tenant: false,
901            },
902            source: SourceProvenance {
903                path: action_path.clone(),
904                pointer: "/intrinsic-actions".to_owned(),
905                line: 1,
906                column: 1,
907            },
908        });
909    }
910    let mut entities = collect_ids(&entity_doc, "entities", "entity_id")?;
911    let forms = collect_ids(&form_doc, "forms", "form_id")?;
912    let mut policies = collect_string_ids(&policy_doc, "capabilities")?;
913    if policies.is_empty() {
914        policies.insert(ContractId::parse("public")?);
915    }
916    let mut role_capability_edges = BTreeSet::new();
917    for edge in policy_doc
918        .get("role_capability_edges")
919        .and_then(Value::as_array)
920        .into_iter()
921        .flatten()
922    {
923        let role = ContractId::parse(
924            edge.get("role")
925                .and_then(Value::as_str)
926                .ok_or_else(|| ApplicationIrError::InvalidDocument("policies".to_owned()))?,
927        )?;
928        let capability = ContractId::parse(
929            edge.get("capability")
930                .and_then(Value::as_str)
931                .ok_or_else(|| ApplicationIrError::InvalidDocument("policies".to_owned()))?,
932        )?;
933        if !policies.contains(&capability) {
934            return Err(ApplicationIrError::MissingReference {
935                owner: role.as_str().to_owned(),
936                target: capability.as_str().to_owned(),
937            });
938        }
939        role_capability_edges.insert((role, capability));
940    }
941    let workflows = collect_ids(&workflow_doc, "workflows", "workflow_id")?;
942    let integrations = collect_ids(&integration_doc, "adapters", "adapter_id")?;
943    if entities.is_empty() {
944        for action in action_doc
945            .get("actions")
946            .and_then(Value::as_array)
947            .into_iter()
948            .flatten()
949        {
950            if let Some(entity_id) = action.get("entity_id").and_then(Value::as_str) {
951                entities.insert(ContractId::parse(entity_id)?);
952            }
953        }
954    }
955    for action in action_doc
956        .get("actions")
957        .and_then(Value::as_array)
958        .into_iter()
959        .flatten()
960    {
961        let owner = action
962            .get("action_id")
963            .and_then(Value::as_str)
964            .unwrap_or("action");
965        validate_reference(owner, action, "entity_id", &entities)?;
966        validate_reference(owner, action, "policy_gate", &policies)?;
967    }
968    for form in form_doc
969        .get("forms")
970        .and_then(Value::as_array)
971        .into_iter()
972        .flatten()
973    {
974        let owner = form
975            .get("form_id")
976            .and_then(Value::as_str)
977            .unwrap_or("form");
978        validate_reference(owner, form, "entity_id", &entities)?;
979        if let Some(action_id) = form.get("submits_action").and_then(Value::as_str) {
980            let action_id = ContractId::parse(action_id)?;
981            if !actions.contains_key(&action_id) {
982                return Err(ApplicationIrError::MissingReference {
983                    owner: owner.to_owned(),
984                    target: action_id.as_str().to_owned(),
985                });
986            }
987        }
988    }
989    for workflow in workflow_doc
990        .get("workflows")
991        .and_then(Value::as_array)
992        .into_iter()
993        .flatten()
994    {
995        let owner = workflow
996            .get("workflow_id")
997            .and_then(Value::as_str)
998            .unwrap_or("workflow");
999        for transition in workflow
1000            .get("transitions")
1001            .and_then(Value::as_array)
1002            .into_iter()
1003            .flatten()
1004        {
1005            if let Some(action_id) = transition.get("action_id").and_then(Value::as_str) {
1006                let action_id = ContractId::parse(action_id)?;
1007                if !actions.contains_key(&action_id) {
1008                    return Err(ApplicationIrError::MissingReference {
1009                        owner: owner.to_owned(),
1010                        target: action_id.as_str().to_owned(),
1011                    });
1012                }
1013            }
1014            validate_reference(owner, transition, "policy_gate", &policies)?;
1015        }
1016    }
1017    let route_rows = route_doc
1018        .get("routes")
1019        .and_then(Value::as_array)
1020        .ok_or_else(|| ApplicationIrError::InvalidDocument("routes".to_owned()))?;
1021    let mut routes = BTreeMap::new();
1022    for (index, row) in route_rows.iter().enumerate() {
1023        let raw_id = row.get("route_id").and_then(Value::as_str).ok_or_else(|| {
1024            ApplicationIrError::InvalidDocument(format!("routes/{index}/route_id"))
1025        })?;
1026        let id = ContractId::parse(raw_id)?;
1027        let action_id = row
1028            .get("action_id")
1029            .and_then(Value::as_str)
1030            .map(ContractId::parse)
1031            .transpose()?;
1032        if let Some(action_id) = action_id.as_ref()
1033            && !actions.contains_key(action_id)
1034        {
1035            return Err(ApplicationIrError::MissingReference {
1036                owner: raw_id.to_owned(),
1037                target: action_id.as_str().to_owned(),
1038            });
1039        }
1040        let policy_id = row
1041            .get("policy_gate")
1042            .and_then(Value::as_str)
1043            .map(ContractId::parse)
1044            .transpose()?;
1045        if let Some(policy_id) = policy_id.as_ref()
1046            && !policies.contains(policy_id)
1047        {
1048            return Err(ApplicationIrError::MissingReference {
1049                owner: raw_id.to_owned(),
1050                target: policy_id.as_str().to_owned(),
1051            });
1052        }
1053        let route = RouteIr {
1054            id: id.clone(),
1055            path: row
1056                .get("path")
1057                .and_then(Value::as_str)
1058                .unwrap_or_default()
1059                .to_owned(),
1060            kind: row
1061                .get("kind")
1062                .and_then(Value::as_str)
1063                .unwrap_or_default()
1064                .to_owned(),
1065            method: row
1066                .get("method")
1067                .and_then(Value::as_str)
1068                .unwrap_or(if row.get("kind").and_then(Value::as_str) == Some("page") {
1069                    "GET"
1070                } else {
1071                    "POST"
1072                })
1073                .to_ascii_uppercase(),
1074            action_id,
1075            page_id: row
1076                .get("page_id")
1077                .and_then(Value::as_str)
1078                .map(ContractId::parse)
1079                .transpose()?,
1080            policy_id,
1081            auth_gate: row
1082                .get("auth_gate")
1083                .and_then(Value::as_str)
1084                .unwrap_or_default()
1085                .to_owned(),
1086            security: route_security_posture(&route_doc, row, raw_id)?,
1087            source: source_provenance(
1088                &route_doc,
1089                &route_path,
1090                format!("/routes/{index}"),
1091                "route_id",
1092                raw_id,
1093            ),
1094            compatibility_view: row.clone(),
1095        };
1096        if routes.insert(id, route).is_some() {
1097            return Err(ApplicationIrError::DuplicateId(raw_id.to_owned()));
1098        }
1099    }
1100    let mut route_buckets: BTreeMap<(String, usize), Vec<ContractId>> = BTreeMap::new();
1101    for route in routes.values() {
1102        let segment_count = route
1103            .path
1104            .trim_matches('/')
1105            .split('/')
1106            .filter(|segment| !segment.is_empty())
1107            .count();
1108        route_buckets
1109            .entry((route.kind.clone(), segment_count))
1110            .or_default()
1111            .push(route.id.clone());
1112    }
1113    Ok(ApplicationIr {
1114        version: APPLICATION_IR_VERSION,
1115        contract_digest: contract_digest(&[
1116            ("routes", &route_doc),
1117            ("actions", &action_doc),
1118            ("entities", &entity_doc),
1119            ("forms", &form_doc),
1120            ("policies", &policy_doc),
1121            ("workflows", &workflow_doc),
1122            ("integrations", &integration_doc),
1123        ])?,
1124        app_id,
1125        routes,
1126        route_buckets,
1127        actions,
1128        entities,
1129        forms,
1130        policies,
1131        role_capability_edges,
1132        workflows,
1133        integrations,
1134    })
1135}
1136
1137#[cfg(test)]
1138mod tests {
1139    use super::compile_action_effects_for_runtime;
1140
1141    #[test]
1142    /// Performs executable effects include runtime owned workflow and delivery writes.
1143    fn executable_effects_include_runtime_owned_workflow_and_delivery_writes() {
1144        let action = serde_json::json!({
1145            "entity_id": "task",
1146            "mutation_primitives": [
1147                {"primitive_kind": "append_record", "collection": "items"},
1148                {"primitive_kind": "set_root_fields"}
1149            ],
1150            "effect_hooks": {
1151                "notifications": [{"adapter_id": "local-mail"}],
1152                "webhook_dispatch": ["audit-webhook"]
1153            }
1154        });
1155        let transition = serde_json::json!({
1156            "emits_event": "task_created",
1157            "scheduled_jobs": ["notify-task-created"]
1158        });
1159        let effects = compile_action_effects_for_runtime(&action, Some(&transition))
1160            .expect("compile executable effects");
1161        assert!(effects.writes.contains("state:items"));
1162        assert!(effects.writes.contains("state:workflow_executions"));
1163        assert!(effects.writes.contains("state:workflow_execution_events"));
1164        assert!(effects.writes.contains("state:action_reports"));
1165        assert!(effects.writes.contains("state:root"));
1166        assert!(effects.emits.contains("task_created"));
1167        assert!(effects.enqueues.contains("notify-task-created"));
1168        assert!(effects.enqueues.contains("notification:local-mail"));
1169        assert!(effects.external_calls.contains("audit-webhook"));
1170    }
1171}