1use std::collections::{BTreeMap, BTreeSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use serde_json::Value;
8use sha2::{Digest, Sha256};
9pub const APPLICATION_IR_VERSION: &str = "pharos.application-ir.v1";
21pub const CONTRACT_VERSION: &str = "1.0";
33
34#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
35pub struct ContractId(String);
51
52impl ContractId {
53 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 pub fn as_str(&self) -> &str {
107 &self.0
108 }
109}
110
111#[derive(Debug, Clone, Eq, PartialEq)]
112pub 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)]
138pub 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)]
166pub 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)]
196pub 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)]
224pub 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)]
264pub 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
305fn 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)]
325pub 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
356fn 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
369fn 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
385fn 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
433fn 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
446fn 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
460pub 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
611fn 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
639fn 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
649fn 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
665fn 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
689fn 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
711fn 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
721fn 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
753fn 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
774pub 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 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}