1#[cfg(test)]
2use std::collections::BTreeMap;
3use std::fs;
4use std::path::{Component, Path, PathBuf};
5
6use crate::config::{AppRuntime, ServeConfig};
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11pub use pharos_runtime_architect::{
12 AgentErrorKind, CredentialBackend, EncryptedFileCredentialBackend, PathClassification,
13 SecretRefMetadata, classify_repo_path, redact_secret_like_text, secret_token_like,
14 validate_secret_ref,
15};
16
17pub use pharos_runtime_architect::json_rpc::{
18 AgentOutput, AgentToolResult, ArtifactKind, ArtifactManifest, CapabilityClass, ExportKind,
19 JsonRpcDispatcher, JsonRpcError, JsonRpcErrorData, JsonRpcFailure, JsonRpcRequest,
20 JsonRpcSuccess, ProviderCompletion, ProviderToolCall, ReleaseLedgerRecord, TargetEnvironment,
21 TargetListFormat, TargetTransport, ToolParams,
22};
23
24pub use pharos_runtime_architect::model::{
25 AppAcceptanceCriterionModel, AppActionModel, AppChangePlan, AppEntityModel, AppManifestModel,
26 AppModel, AppModelSynthesis, AppRouteModel, AppStateMutationModel, AppStaticAssetModel,
27 AppTemplateModel, AppUiArchetypeModel, AppWorkflowModel, FilePatch, PlannedFileChange,
28 app_model_digest, normalize_app_id,
29};
30
31#[cfg(test)]
32const RPC_REQUEST_LIMIT_BYTES: usize = 1024 * 1024;
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct TargetProfile {
36 pub schema: String,
37 pub target_id: String,
38 pub environment: TargetEnvironment,
39 pub transport: TargetTransport,
40 pub artifact_kind: ArtifactKind,
41 #[serde(default)]
42 pub allowed_app_ids: Vec<String>,
43 #[serde(default)]
44 pub secret_refs: Vec<String>,
45}
46
47pub use pharos_runtime_architect::change_plan::{
48 AgentOperationLock, acquire_operation_lock, apply_app_change, inspect_change_plan,
49 plan_app_change, plan_digest_current, resolve_authoring_output_root,
50 resolve_existing_repo_path,
51};
52
53pub use pharos_runtime_architect::builder_model::{
54 builtin_authoring_patches, synthesize_builtin_app_model,
55};
56
57struct RuntimeJsonRpcDispatcher;
58
59impl JsonRpcDispatcher for RuntimeJsonRpcDispatcher {
60 fn synthesize_app_model(&self, request: JsonRpcRequest) -> String {
61 execute_synthesize_app_model(request)
62 }
63
64 fn scaffold_app(&self, request: JsonRpcRequest) -> String {
65 execute_scaffold_app(request)
66 }
67
68 fn read_app_model(&self, request: JsonRpcRequest) -> String {
69 execute_read_app_model(request)
70 }
71
72 fn plan_app_change(&self, request: JsonRpcRequest) -> String {
73 execute_plan_app_change(request)
74 }
75
76 fn inspect_change(&self, request: JsonRpcRequest) -> String {
77 execute_inspect_change(request)
78 }
79
80 fn apply_app_change(&self, request: JsonRpcRequest) -> String {
81 execute_apply_app_change(request)
82 }
83
84 fn lifecycle_app(&self, request: JsonRpcRequest, verb: &'static str) -> String {
85 execute_lifecycle_app(request, verb)
86 }
87
88 fn seed_app(&self, request: JsonRpcRequest) -> String {
89 execute_seed_app(request)
90 }
91
92 fn validate_local(&self, request: JsonRpcRequest) -> String {
93 execute_validate_local(request)
94 }
95
96 fn backup_app(&self, request: JsonRpcRequest) -> String {
97 execute_backup_app(request)
98 }
99
100 fn restore_app(&self, request: JsonRpcRequest) -> String {
101 execute_restore_app(request)
102 }
103
104 fn jobs_app(&self, request: JsonRpcRequest, operation: &'static str) -> String {
105 execute_jobs_app(request, operation)
106 }
107
108 fn integrations_app(&self, request: JsonRpcRequest, operation: &'static str) -> String {
109 execute_integrations_app(request, operation)
110 }
111
112 fn deploy_target(&self, request: JsonRpcRequest) -> String {
113 execute_deploy_target(request)
114 }
115
116 fn list_targets(&self, request: JsonRpcRequest) -> String {
117 execute_list_targets(request)
118 }
119
120 fn host_detect(&self, request: JsonRpcRequest) -> String {
121 execute_host_detect(request)
122 }
123
124 fn production_readiness(&self, request: JsonRpcRequest) -> String {
125 execute_production_readiness(request)
126 }
127}
128
129pub fn execute_json_rpc(input: &str) -> String {
131 pharos_runtime_architect::json_rpc::execute_json_rpc_with_dispatcher(
132 input,
133 &RuntimeJsonRpcDispatcher,
134 )
135}
136
137fn execute_synthesize_app_model(request: JsonRpcRequest) -> String {
139 pharos_runtime_architect::json_rpc::execute_synthesize_app_model(request)
140}
141
142fn execute_scaffold_app(request: JsonRpcRequest) -> String {
144 let params: ScaffoldAppParams = match serde_json::from_value(request.params) {
145 Ok(params) => params,
146 Err(_) => return json_rpc_failure(Some(request.id), AgentErrorKind::InvalidEnvelope),
147 };
148 let app_id = match normalize_app_id(¶ms.app_id) {
149 Ok(app_id) => app_id,
150 Err(error) => return json_rpc_failure(Some(request.id), error),
151 };
152 if params
153 .template
154 .as_deref()
155 .is_some_and(|template| !matches!(template, "dynamic" | "dynamic_declarative"))
156 {
157 return json_rpc_failure(Some(request.id), AgentErrorKind::PolicyViolation);
158 }
159 let output_root = match resolve_authoring_output_root(¶ms.output_root) {
160 Ok(output_root) => output_root,
161 Err(error) => return json_rpc_failure(Some(request.id), error),
162 };
163 let repo_root = match default_repo_root() {
164 Ok(repo_root) => repo_root,
165 Err(error) => return json_rpc_failure(Some(request.id), error),
166 };
167 let _lock = match acquire_operation_lock(&repo_root) {
168 Ok(lock) => lock,
169 Err(error) => return json_rpc_failure(Some(request.id), error),
170 };
171 match pharos_runtime_architect::scaffold::scaffold_app(&app_id, &output_root) {
172 Ok(result) => match serde_json::from_str::<Value>(&result) {
173 Ok(mut value) => {
174 if let Some(app_root) = value.get("app_root").and_then(Value::as_str) {
175 let relative = repo_relative_path(&repo_root, Path::new(app_root));
176 if let Some(object) = value.as_object_mut() {
177 object.insert("app_root".to_owned(), Value::String(relative));
178 }
179 }
180 json_rpc_success(request.id, value)
181 }
182 Err(_) => json_rpc_failure(Some(request.id), AgentErrorKind::InvalidEnvelope),
183 },
184 Err(64) => json_rpc_failure(Some(request.id), AgentErrorKind::InvalidIdentifier),
185 Err(75) => json_rpc_failure(Some(request.id), AgentErrorKind::LockBusy),
186 Err(73) => json_rpc_failure(Some(request.id), AgentErrorKind::InvalidPath),
187 Err(_) => json_rpc_failure(Some(request.id), AgentErrorKind::PolicyViolation),
188 }
189}
190
191#[derive(Debug, Deserialize)]
192#[serde(deny_unknown_fields)]
193struct ScaffoldAppParams {
194 app_id: String,
195 output_root: String,
196 template: Option<String>,
197}
198
199fn execute_read_app_model(request: JsonRpcRequest) -> String {
201 let params: ReadAppModelParams = match serde_json::from_value(request.params) {
202 Ok(params) => params,
203 Err(_) => return json_rpc_failure(Some(request.id), AgentErrorKind::InvalidEnvelope),
204 };
205 match read_existing_app_model(¶ms.app_id) {
206 Ok(value) => json_rpc_success(request.id, value),
207 Err(error) => json_rpc_failure(Some(request.id), error),
208 }
209}
210
211#[derive(Debug, Deserialize)]
212#[serde(deny_unknown_fields)]
213struct ReadAppModelParams {
214 app_id: String,
215}
216
217pub fn read_existing_app_model(app_id: &str) -> Result<Value, AgentErrorKind> {
219 let repo_root = default_repo_root()?;
220 pharos_runtime_architect::product_knowledge::read_existing_app_model(&repo_root, app_id)
221}
222
223fn execute_plan_app_change(request: JsonRpcRequest) -> String {
225 pharos_runtime_architect::json_rpc::execute_plan_app_change(request)
226}
227
228fn execute_inspect_change(request: JsonRpcRequest) -> String {
230 pharos_runtime_architect::json_rpc::execute_inspect_change(request)
231}
232
233fn execute_apply_app_change(request: JsonRpcRequest) -> String {
235 pharos_runtime_architect::json_rpc::execute_apply_app_change(request)
236}
237
238#[derive(Debug, Deserialize)]
239#[serde(deny_unknown_fields)]
240struct LifecycleAppParams {
241 app_id: String,
242 app_dir: Option<String>,
243 build_dir: Option<String>,
244 conf: Option<String>,
245 backend: Option<String>,
246}
247
248fn execute_lifecycle_app(request: JsonRpcRequest, verb: &str) -> String {
250 let params: LifecycleAppParams = match serde_json::from_value(request.params) {
251 Ok(params) => params,
252 Err(_) => return json_rpc_failure(Some(request.id), AgentErrorKind::InvalidEnvelope),
253 };
254 match run_lifecycle_app_tool(
255 verb,
256 ¶ms.app_id,
257 params.app_dir.as_deref(),
258 params.build_dir.as_deref(),
259 params.conf.as_deref(),
260 ) {
261 Ok(value) => json_rpc_success(request.id, value),
262 Err(error) => json_rpc_failure(Some(request.id), error),
263 }
264}
265
266fn execute_seed_app(request: JsonRpcRequest) -> String {
268 let params: LifecycleAppParams = match serde_json::from_value(request.params) {
269 Ok(params) => params,
270 Err(_) => return json_rpc_failure(Some(request.id), AgentErrorKind::InvalidEnvelope),
271 };
272 match seed_app_tool(
273 ¶ms.app_id,
274 params.app_dir.as_deref(),
275 params.conf.as_deref(),
276 params.backend.as_deref().unwrap_or(""),
277 ) {
278 Ok(value) => json_rpc_success(request.id, value),
279 Err(error) => json_rpc_failure(Some(request.id), error),
280 }
281}
282
283#[derive(Debug, Deserialize)]
284#[serde(deny_unknown_fields)]
285struct BackupAppParams {
286 app_id: String,
287 app_dir: Option<String>,
288 build_dir: Option<String>,
289 conf: Option<String>,
290 backend: Option<String>,
291 state_dir: Option<String>,
292 state_path: Option<String>,
293 output_dir: Option<String>,
294}
295
296#[derive(Debug, Deserialize)]
297#[serde(deny_unknown_fields)]
298struct RestoreAppParams {
299 app_id: String,
300 app_dir: Option<String>,
301 build_dir: Option<String>,
302 conf: Option<String>,
303 backend: Option<String>,
304 state_dir: Option<String>,
305 state_path: Option<String>,
306 input_dir: String,
307}
308
309#[derive(Debug, Deserialize)]
310#[serde(deny_unknown_fields)]
311struct JobsAppParams {
312 app_id: String,
313 app_dir: Option<String>,
314 conf: Option<String>,
315 backend: Option<String>,
316 db_path: Option<String>,
317 case: Option<String>,
318}
319
320#[derive(Debug, Deserialize)]
321#[serde(deny_unknown_fields)]
322pub struct IntegrationsAppParams {
323 pub app_id: String,
324 pub build_dir: Option<String>,
325 pub conf: Option<String>,
326 pub state_path: String,
327 pub adapter_id: Option<String>,
328 pub event_kind: Option<String>,
329 pub resource_id: Option<String>,
330 pub tenant_id: Option<String>,
331 pub subject_id: Option<String>,
332 pub payload_json: Option<String>,
333}
334
335#[derive(Debug, Deserialize)]
336#[serde(deny_unknown_fields)]
337struct DeployTargetParams {
338 app_id: String,
339 target_id: String,
340 app_dir: Option<String>,
341 artifact_root: Option<String>,
342}
343
344#[derive(Debug, Deserialize)]
345#[serde(deny_unknown_fields)]
346struct ListTargetsParams {
347 format: Option<TargetListFormat>,
348}
349
350#[derive(Debug, Deserialize)]
351#[serde(deny_unknown_fields)]
352struct ProductionReadinessParams {
353 app_id: Option<String>,
354}
355
356fn execute_validate_local(request: JsonRpcRequest) -> String {
358 let params: LifecycleAppParams = match serde_json::from_value(request.params) {
359 Ok(params) => params,
360 Err(_) => return json_rpc_failure(Some(request.id), AgentErrorKind::InvalidEnvelope),
361 };
362 let build = run_lifecycle_app_tool(
363 "build",
364 ¶ms.app_id,
365 params.app_dir.as_deref(),
366 params.build_dir.as_deref(),
367 params.conf.as_deref(),
368 );
369 let verify = build.as_ref().ok().and_then(|_| {
370 run_lifecycle_app_tool(
371 "verify",
372 ¶ms.app_id,
373 params.app_dir.as_deref(),
374 params.build_dir.as_deref(),
375 params.conf.as_deref(),
376 )
377 .ok()
378 });
379 let doctor = verify.as_ref().and_then(|_| {
380 run_lifecycle_app_tool(
381 "doctor",
382 ¶ms.app_id,
383 params.app_dir.as_deref(),
384 params.build_dir.as_deref(),
385 params.conf.as_deref(),
386 )
387 .ok()
388 });
389 match (build, verify, doctor) {
390 (Ok(build), Some(verify), Some(doctor)) => json_rpc_success(
391 request.id,
392 serde_json::json!({
393 "schema": "pharos.agent.validate_local.v1",
394 "status": "validated",
395 "app_id": params.app_id,
396 "build": build,
397 "verify": verify,
398 "doctor": doctor
399 }),
400 ),
401 (Err(error), _, _) => json_rpc_failure(Some(request.id), error),
402 _ => json_rpc_failure(Some(request.id), AgentErrorKind::PolicyViolation),
403 }
404}
405
406fn execute_backup_app(request: JsonRpcRequest) -> String {
408 let params: BackupAppParams = match serde_json::from_value(request.params) {
409 Ok(params) => params,
410 Err(_) => return json_rpc_failure(Some(request.id), AgentErrorKind::InvalidEnvelope),
411 };
412 match backup_restore_runtime_context(
413 ¶ms.app_id,
414 params.app_dir.as_deref(),
415 params.build_dir.as_deref(),
416 params.conf.as_deref(),
417 params.state_dir.as_deref(),
418 params.state_path.as_deref(),
419 )
420 .and_then(|(config, app)| {
421 let output_dir = params
422 .output_dir
423 .as_deref()
424 .and_then(normalize_lifecycle_path);
425 super::backup::build_backup_report_local_json(
426 &config,
427 &app,
428 params.backend.as_deref(),
429 output_dir.as_deref(),
430 )
431 .map_err(|_| AgentErrorKind::PolicyViolation)
432 .and_then(|text| {
433 serde_json::from_str::<Value>(&text).map_err(|_| AgentErrorKind::InvalidEnvelope)
434 })
435 }) {
436 Ok(value) => json_rpc_success(request.id, value),
437 Err(error) => json_rpc_failure(Some(request.id), error),
438 }
439}
440
441fn execute_restore_app(request: JsonRpcRequest) -> String {
443 let params: RestoreAppParams = match serde_json::from_value(request.params) {
444 Ok(params) => params,
445 Err(_) => return json_rpc_failure(Some(request.id), AgentErrorKind::InvalidEnvelope),
446 };
447 match backup_restore_runtime_context(
448 ¶ms.app_id,
449 params.app_dir.as_deref(),
450 params.build_dir.as_deref(),
451 params.conf.as_deref(),
452 params.state_dir.as_deref(),
453 params.state_path.as_deref(),
454 )
455 .and_then(|(config, app)| {
456 let input_dir =
457 normalize_lifecycle_path(¶ms.input_dir).ok_or(AgentErrorKind::InvalidPath)?;
458 super::backup::build_restore_report_local_json(
459 &config,
460 &app,
461 params.backend.as_deref(),
462 &input_dir,
463 )
464 .map_err(|_| AgentErrorKind::PolicyViolation)
465 .and_then(|text| {
466 serde_json::from_str::<Value>(&text).map_err(|_| AgentErrorKind::InvalidEnvelope)
467 })
468 }) {
469 Ok(value) => json_rpc_success(request.id, value),
470 Err(error) => json_rpc_failure(Some(request.id), error),
471 }
472}
473
474fn execute_jobs_app(request: JsonRpcRequest, operation: &str) -> String {
476 let params: JobsAppParams = match serde_json::from_value(request.params) {
477 Ok(params) => params,
478 Err(_) => return json_rpc_failure(Some(request.id), AgentErrorKind::InvalidEnvelope),
479 };
480 match run_jobs_app_tool(¶ms, operation) {
481 Ok(value) => json_rpc_success(request.id, value),
482 Err(error) => json_rpc_failure(Some(request.id), error),
483 }
484}
485
486fn execute_integrations_app(request: JsonRpcRequest, operation: &str) -> String {
488 let params: IntegrationsAppParams = match serde_json::from_value(request.params) {
489 Ok(params) => params,
490 Err(_) => return json_rpc_failure(Some(request.id), AgentErrorKind::InvalidEnvelope),
491 };
492 match run_integrations_app_tool(¶ms, operation) {
493 Ok(value) => json_rpc_success(request.id, value),
494 Err(error) => json_rpc_failure(Some(request.id), error),
495 }
496}
497
498fn run_jobs_app_tool(params: &JobsAppParams, operation: &str) -> Result<Value, AgentErrorKind> {
500 if !matches!(operation, "inspect" | "run") {
501 return Err(AgentErrorKind::MethodNotFound);
502 }
503 let app_id = normalize_app_id(¶ms.app_id)?;
504 let app_dir = params
505 .app_dir
506 .as_deref()
507 .and_then(normalize_lifecycle_path)
508 .unwrap_or_else(|| PathBuf::from(format!("apps/{app_id}")));
509 if !app_dir.join("pharos.app.yml").is_file() {
510 return Err(AgentErrorKind::InvalidPath);
511 }
512 let store = super::database::source_app_operations_store(
513 &app_dir,
514 params.conf.as_deref().map(Path::new),
515 &app_id,
516 None,
517 )
518 .map_err(|_| AgentErrorKind::PolicyViolation)?;
519 if params
520 .backend
521 .as_deref()
522 .is_some_and(|backend| backend != store.config.backend)
523 {
524 return Err(AgentErrorKind::PolicyViolation);
525 }
526 if let Some(path) = params.db_path.as_deref() {
527 let path = normalize_lifecycle_path(path).ok_or(AgentErrorKind::InvalidPath)?;
528 if !store.config.matches_local_path_hint(&path) {
529 return Err(AgentErrorKind::PolicyViolation);
530 }
531 }
532 if operation == "run" {
533 pharos_runtime_database::queue::complete_one_local_job(
534 store.adapter.as_ref(),
535 &store.config,
536 &store.schema,
537 )
538 .map_err(|_| AgentErrorKind::PolicyViolation)?;
539 }
540 let (jobs, status_counts) = pharos_runtime_database::queue::local_job_report(
541 store.adapter.as_ref(),
542 &store.config,
543 &store.schema,
544 )
545 .map_err(|_| AgentErrorKind::PolicyViolation)?;
546 Ok(serde_json::json!({
547 "schema": "pharos.agent.jobs.v1",
548 "operation": operation,
549 "app_id": app_id,
550 "db_path": store.config.sqlite_path.as_ref().map(|path| path.to_string_lossy().into_owned()),
551 "conf": params.conf,
552 "case": params.case,
553 "status": "ok",
554 "jobs": jobs,
555 "status_counts": status_counts,
556 }))
557}
558
559pub fn run_integrations_app_tool(
561 params: &IntegrationsAppParams,
562 operation: &str,
563) -> Result<Value, AgentErrorKind> {
564 if !matches!(operation, "inspect" | "deliver") {
565 return Err(AgentErrorKind::MethodNotFound);
566 }
567 let app_id = normalize_app_id(¶ms.app_id)?;
568 let build_dir = params
569 .build_dir
570 .as_deref()
571 .and_then(normalize_lifecycle_path)
572 .unwrap_or_else(|| PathBuf::from("dist"));
573 let built_app_root = build_dir.join("apps").join(&app_id);
574 let runtime_conf = params.conf.as_deref().map(Path::new);
575 let app_root = super::runtime_apps_dir(runtime_conf)
576 .map(PathBuf::from)
577 .map(|apps_dir| apps_dir.join(&app_id))
578 .filter(|candidate| candidate.is_dir())
579 .unwrap_or_else(|| built_app_root.clone());
580 let app = AppRuntime {
581 app_id: Some(app_id.clone()),
582 app_root: Some(app_root.to_string_lossy().into_owned()),
583 artifact_dir: Some(built_app_root.to_string_lossy().into_owned()),
584 runtime_conf_path: params.conf.clone(),
585 ..AppRuntime::default()
586 };
587 if super::database::relational_runtime_authority_enabled(&app_root) {
588 let store = super::database::runtime_logical_app_store(&app)
589 .map_err(|_| AgentErrorKind::PolicyViolation)?;
590 if operation == "inspect" {
591 let mut root = serde_json::json!({});
592 for (entity, section) in [
593 ("integration_delivery", "integration_deliveries"),
594 (
595 "integration_delivery_attempt",
596 "integration_delivery_attempts",
597 ),
598 ("webhook_receipt", "webhook_receipts"),
599 ] {
600 let rows = pharos_runtime_database::integration::integration_rows(
601 store.adapter.as_ref(),
602 &store.config,
603 &store.schema,
604 entity,
605 256,
606 )
607 .map_err(|_| AgentErrorKind::PolicyViolation)?;
608 root[section] = Value::Array(rows);
609 }
610 let report = super::business::dynamic_integrations_inspect_report_json(&root)
611 .ok_or(AgentErrorKind::InvalidEnvelope)?;
612 return serde_json::from_str(&report).map_err(|_| AgentErrorKind::InvalidEnvelope);
613 }
614 let integrations_text =
615 fs::read_to_string(built_app_root.join("data/contracts/integrations.json"))
616 .map_err(|_| AgentErrorKind::InvalidPath)?;
617 let integrations_root: Value = serde_json::from_str(&integrations_text)
618 .map_err(|_| AgentErrorKind::InvalidEnvelope)?;
619 let mut root = serde_json::json!({"integration_deliveries": []});
620 let report = super::business::dynamic_integrations_deliver_report_json(
621 &mut root,
622 &integrations_root,
623 params
624 .adapter_id
625 .as_deref()
626 .ok_or(AgentErrorKind::InvalidIdentifier)?,
627 params.event_kind.as_deref(),
628 params.resource_id.as_deref(),
629 params.tenant_id.as_deref(),
630 params.subject_id.as_deref(),
631 params.payload_json.as_deref().unwrap_or("{}"),
632 )
633 .ok_or(AgentErrorKind::PolicyViolation)?;
634 let row = root["integration_deliveries"][0]
635 .as_object()
636 .ok_or(AgentErrorKind::InvalidEnvelope)?;
637 let id = pharos_runtime_database::integration::enqueue_delivery(
638 store.adapter.as_ref(),
639 &store.config,
640 &store.schema,
641 row,
642 )
643 .map_err(|error| {
644 eprintln!("integration enqueue failed: {error:?}");
645 AgentErrorKind::PolicyViolation
646 })?;
647 let mut report: Value =
648 serde_json::from_str(&report).map_err(|_| AgentErrorKind::InvalidEnvelope)?;
649 report["processed"][0]["id"] = Value::from(id);
650 report["deliveries"][0]["id"] = Value::from(id);
651 return Ok(report);
652 }
653 let state_path =
654 normalize_lifecycle_path(¶ms.state_path).ok_or(AgentErrorKind::InvalidPath)?;
655 let _guard = super::state_store::StateMutationGuard::acquire(&state_path)
656 .map_err(|_| AgentErrorKind::LockBusy)?;
657 let original_state_text =
658 fs::read_to_string(&state_path).map_err(|_| AgentErrorKind::InvalidPath)?;
659 let mut root: Value =
660 serde_json::from_str(&original_state_text).map_err(|_| AgentErrorKind::InvalidEnvelope)?;
661 if operation == "inspect" {
662 let report = super::business::dynamic_integrations_inspect_report_json(&root)
663 .ok_or(AgentErrorKind::InvalidEnvelope)?;
664 let mut report: Value =
665 serde_json::from_str(&report).map_err(|_| AgentErrorKind::InvalidEnvelope)?;
666 if let Ok(store) = super::database::runtime_logical_app_store(&app)
667 && store
668 .schema
669 .entities
670 .iter()
671 .any(|entity| entity.name == "webhook_receipt")
672 {
673 let count = store
674 .adapter
675 .aggregate(
676 &store.config,
677 &store.schema,
678 &pharos_runtime_database::operations::AggregateQuery {
679 entity: "webhook_receipt".to_owned(),
680 filter: None,
681 aggregate: pharos_runtime_database::operations::Aggregate::Count,
682 },
683 )
684 .map_err(|_| AgentErrorKind::PolicyViolation)?;
685 report["counts"]["webhook_receipts"] = count;
686 }
687 return Ok(report);
688 }
689 let integrations_text =
690 fs::read_to_string(built_app_root.join("data/contracts/integrations.json"))
691 .map_err(|_| AgentErrorKind::InvalidPath)?;
692 let integrations_root: Value =
693 serde_json::from_str(&integrations_text).map_err(|_| AgentErrorKind::InvalidEnvelope)?;
694 let report = super::business::dynamic_integrations_deliver_report_json(
695 &mut root,
696 &integrations_root,
697 params
698 .adapter_id
699 .as_deref()
700 .ok_or(AgentErrorKind::InvalidIdentifier)?,
701 params.event_kind.as_deref(),
702 params.resource_id.as_deref(),
703 params.tenant_id.as_deref(),
704 params.subject_id.as_deref(),
705 params.payload_json.as_deref().unwrap_or("{}"),
706 )
707 .ok_or(AgentErrorKind::PolicyViolation)?;
708 let state_text = serde_json::to_string(&root).map_err(|_| AgentErrorKind::InvalidEnvelope)?;
709 super::fs::write_text_file(&state_path, &state_text)
710 .eq(&0)
711 .then_some(())
712 .ok_or(AgentErrorKind::InvalidPath)?;
713 let value: Value =
714 serde_json::from_str(&report).map_err(|_| AgentErrorKind::InvalidEnvelope)?;
715 if params.conf.is_some() {
716 }
718 Ok(value)
719}
720
721fn execute_deploy_target(request: JsonRpcRequest) -> String {
723 let params: DeployTargetParams = match serde_json::from_value(request.params) {
724 Ok(params) => params,
725 Err(_) => return json_rpc_failure(Some(request.id), AgentErrorKind::InvalidEnvelope),
726 };
727 match deploy_target_gate(¶ms) {
728 Ok(value) => json_rpc_success(request.id, value),
729 Err(error) => json_rpc_failure(Some(request.id), error),
730 }
731}
732
733fn execute_list_targets(request: JsonRpcRequest) -> String {
735 let params: ListTargetsParams = match serde_json::from_value(request.params) {
736 Ok(params) => params,
737 Err(_) => return json_rpc_failure(Some(request.id), AgentErrorKind::InvalidEnvelope),
738 };
739 let repo_root = match default_repo_root() {
740 Ok(repo_root) => repo_root,
741 Err(error) => return json_rpc_failure(Some(request.id), error),
742 };
743 let output = match params.format.unwrap_or(TargetListFormat::Json) {
744 TargetListFormat::Json => target_registry_projection(&repo_root),
745 TargetListFormat::Text => target_registry_list_text(&repo_root).map(|text| {
746 serde_json::json!({"schema":"pharos.targets.text.v1","text":text}).to_string()
747 }),
748 };
749 match output.and_then(|text| serde_json::from_str::<Value>(&text).map_err(|_| 70)) {
750 Ok(value) => json_rpc_success(request.id, value),
751 Err(_) => json_rpc_failure(Some(request.id), AgentErrorKind::InvalidEnvelope),
752 }
753}
754
755fn execute_host_detect(request: JsonRpcRequest) -> String {
757 if !request.params.is_null()
758 && !request
759 .params
760 .as_object()
761 .is_some_and(|object| object.is_empty())
762 {
763 return json_rpc_failure(Some(request.id), AgentErrorKind::InvalidEnvelope);
764 }
765 let repo_root = match default_repo_root() {
766 Ok(repo_root) => repo_root,
767 Err(error) => return json_rpc_failure(Some(request.id), error),
768 };
769 let text = host_detection_report(&repo_root, std::env::consts::OS, std::env::consts::ARCH);
770 match text.and_then(|text| serde_json::from_str::<Value>(&text).map_err(|_| 70)) {
771 Ok(value) => json_rpc_success(request.id, value),
772 Err(_) => json_rpc_failure(Some(request.id), AgentErrorKind::InvalidEnvelope),
773 }
774}
775
776fn execute_production_readiness(request: JsonRpcRequest) -> String {
778 let params: ProductionReadinessParams = match serde_json::from_value(request.params) {
779 Ok(params) => params,
780 Err(_) => return json_rpc_failure(Some(request.id), AgentErrorKind::InvalidEnvelope),
781 };
782 match production_readiness_report(params.app_id.as_deref()) {
783 Ok(value) => json_rpc_success(request.id, value),
784 Err(error) => json_rpc_failure(Some(request.id), error),
785 }
786}
787
788pub fn run_lifecycle_app_tool(
790 verb: &str,
791 app_id: &str,
792 app_dir: Option<&str>,
793 build_dir: Option<&str>,
794 conf: Option<&str>,
795) -> Result<Value, AgentErrorKind> {
796 if !matches!(verb, "build" | "verify" | "doctor") {
797 return Err(AgentErrorKind::MethodNotFound);
798 }
799 let app_id = normalize_app_id(app_id)?;
800 let app_dir = normalize_lifecycle_path(app_dir.unwrap_or(""))
801 .unwrap_or_else(|| PathBuf::from(format!("apps/{app_id}")));
802 if !app_dir.join("pharos.app.yml").is_file() {
803 return Err(AgentErrorKind::InvalidPath);
804 }
805 let build_dir = normalize_lifecycle_path(build_dir.unwrap_or("dist"))
806 .unwrap_or_else(|| PathBuf::from("dist"));
807 let build_dir = if build_dir.is_absolute() {
808 build_dir
809 } else {
810 std::env::current_dir()
811 .map_err(|_| AgentErrorKind::InvalidPath)?
812 .join(build_dir)
813 };
814 let conf_path = conf.and_then(normalize_lifecycle_path);
815 let mut config_record = ServeConfig {
816 kind: Some("app".to_owned()),
817 target_id: Some(app_id.clone()),
818 conf_path: conf_path
819 .as_ref()
820 .map(|path| path.to_string_lossy().into_owned()),
821 app_dir: Some(app_dir.to_string_lossy().into_owned()),
822 build_dir: Some(build_dir.to_string_lossy().into_owned()),
823 ..ServeConfig::default()
824 };
825 let workspace_root = std::env::current_dir().map_err(|_| AgentErrorKind::InvalidPath)?;
828 let report = super::handle_runtime_command_direct(
829 verb,
830 &mut config_record,
831 Some(workspace_root.as_path()),
832 None,
833 )
834 .map_err(|_| AgentErrorKind::PolicyViolation)?;
835 serde_json::from_str(&report).map_err(|_| AgentErrorKind::InvalidEnvelope)
836}
837
838pub fn seed_app_tool(
840 app_id: &str,
841 app_dir: Option<&str>,
842 conf: Option<&str>,
843 backend: &str,
844) -> Result<Value, AgentErrorKind> {
845 let app_id = normalize_app_id(app_id)?;
846 let app_dir = normalize_lifecycle_path(app_dir.unwrap_or(""))
847 .unwrap_or_else(|| PathBuf::from(format!("apps/{app_id}")));
848 if !app_dir.join("pharos.app.yml").is_file() {
849 return Err(AgentErrorKind::InvalidPath);
850 }
851 let conf_path = conf.and_then(normalize_lifecycle_path);
852 let config =
853 super::database::load_cli_backend_config(&app_dir, conf_path.as_deref(), Some(&app_id))
854 .ok_or(AgentErrorKind::PolicyViolation)?;
855 if !backend.is_empty() && config.backend != backend {
856 return Err(AgentErrorKind::PolicyViolation);
857 }
858 if let Some(contract) = super::database::source_selected_logical_contract(
859 &app_dir,
860 conf_path.as_deref(),
861 &app_id,
862 None,
863 )
864 .map_err(|_| AgentErrorKind::PolicyViolation)?
865 {
866 let adapter = super::database::operations::resolve(&config)
867 .map_err(|_| AgentErrorKind::PolicyViolation)?;
868 adapter
869 .ensure_database(&config, &contract.schema)
870 .map_err(|_| AgentErrorKind::PolicyViolation)?;
871 adapter
872 .apply_contract(&config, &contract)
873 .map_err(|_| AgentErrorKind::PolicyViolation)?;
874 return Ok(serde_json::json!({
875 "schema": "pharos.agent.seed_app.v1",
876 "status": "seeded",
877 "app_id": app_id,
878 "backend": config.backend
879 }));
880 }
881 Ok(serde_json::json!({
884 "schema": "pharos.agent.seed_app.v1",
885 "status": "no_seed_required",
886 "app_id": app_id,
887 "backend": config.backend
888 }))
889}
890
891fn normalize_lifecycle_path(value: &str) -> Option<PathBuf> {
893 let trimmed = value.trim();
894 if trimmed.is_empty() {
895 return None;
896 }
897 let path = Path::new(trimmed);
898 if path.is_absolute() {
899 return None;
900 }
901 if path
902 .components()
903 .any(|component| matches!(component, Component::ParentDir))
904 {
905 return None;
906 }
907 Some(path.to_path_buf())
908}
909
910fn backup_restore_runtime_context(
912 app_id: &str,
913 app_dir: Option<&str>,
914 build_dir: Option<&str>,
915 conf: Option<&str>,
916 state_dir: Option<&str>,
917 state_path: Option<&str>,
918) -> Result<(ServeConfig, AppRuntime), AgentErrorKind> {
919 let app_id = normalize_app_id(app_id)?;
920 let app_dir = normalize_lifecycle_path(app_dir.unwrap_or(""))
921 .unwrap_or_else(|| PathBuf::from(format!("apps/{app_id}")));
922 if !app_dir.is_dir() || !app_dir.join("pharos.app.yml").is_file() {
923 return Err(AgentErrorKind::InvalidPath);
924 }
925 let build_dir_path = normalize_lifecycle_path(build_dir.unwrap_or("dist"))
926 .unwrap_or_else(|| PathBuf::from("dist"));
927 let conf_path = conf.and_then(normalize_lifecycle_path);
928 let state_dir_path = state_dir.and_then(normalize_lifecycle_path);
929 let state_path_value = state_path
930 .and_then(normalize_lifecycle_path)
931 .or_else(|| Some(PathBuf::from(format!("tmp/agent-state/{app_id}.json"))));
932 let mut config_record = ServeConfig {
933 kind: Some("app".to_owned()),
934 target_id: Some(app_id.clone()),
935 conf_path: conf_path
936 .as_ref()
937 .map(|path| path.to_string_lossy().into_owned()),
938 app_dir: Some(app_dir.to_string_lossy().into_owned()),
939 build_dir: Some(build_dir_path.to_string_lossy().into_owned()),
940 state_dir: state_dir_path
941 .as_ref()
942 .map(|path| path.to_string_lossy().into_owned()),
943 state_path: state_path_value
944 .as_ref()
945 .map(|path| path.to_string_lossy().into_owned()),
946 ..ServeConfig::default()
947 };
948 crate::config::apply_serve_config_defaults_from_conf(&mut config_record);
949 let state_path = config_record
950 .state_path
951 .clone()
952 .or_else(|| state_path_value.map(|path| path.to_string_lossy().into_owned()))
953 .ok_or(AgentErrorKind::InvalidPath)?;
954 let app = AppRuntime {
955 app_id: Some(app_id),
956 app_root: Some(app_dir.to_string_lossy().into_owned()),
957 build_dir: Some(build_dir_path.to_string_lossy().into_owned()),
958 state_path: Some(state_path),
959 runtime_conf_path: config_record.conf_path.clone(),
960 ..AppRuntime::default()
961 };
962 Ok((config_record, app))
963}
964
965fn deploy_target_gate(params: &DeployTargetParams) -> Result<Value, AgentErrorKind> {
967 let app_id = normalize_app_id(¶ms.app_id)?;
968 let target_id = normalize_app_id(¶ms.target_id)?;
969 let repo_root = default_repo_root()?;
970 let artifact_root = params
971 .artifact_root
972 .as_deref()
973 .and_then(normalize_lifecycle_path)
974 .unwrap_or_else(|| PathBuf::from(format!("dist/apps/{app_id}")));
975 let app_dir = params
976 .app_dir
977 .as_deref()
978 .and_then(normalize_lifecycle_path)
979 .unwrap_or_else(|| artifact_root.clone());
980 let absolute_app_dir = repo_root.join(&app_dir);
981 let check_text =
982 super::ops_bridge::build_app_deploy_check_report_local_json(&absolute_app_dir, &target_id)
983 .map_err(|_| AgentErrorKind::InvalidPath)?;
984 let deploy_check: Value =
985 serde_json::from_str(&check_text).map_err(|_| AgentErrorKind::InvalidEnvelope)?;
986 let registry_text =
987 target_registry_projection(&repo_root).map_err(|_| AgentErrorKind::InvalidPath)?;
988 let registry: Value =
989 serde_json::from_str(®istry_text).map_err(|_| AgentErrorKind::InvalidEnvelope)?;
990 let target_declared = registry
991 .get("targets")
992 .and_then(Value::as_array)
993 .is_some_and(|targets| {
994 targets.iter().any(|target| {
995 target.get("target_id").and_then(Value::as_str) == Some(target_id.as_str())
996 })
997 });
998 let status =
999 if deploy_check.get("status").and_then(Value::as_str) == Some("ok") && target_declared {
1000 "preflight_ready"
1001 } else {
1002 "blocked"
1003 };
1004 Ok(serde_json::json!({
1005 "schema": "pharos.agent.deploy_target_gate.v1",
1006 "status": status,
1007 "app_id": app_id,
1008 "target_id": target_id,
1009 "target_declared": target_declared,
1010 "deploy_check": deploy_check,
1011 "remote_execution": {
1012 "status": "not_enabled",
1013 "reason": "remote target deployment requires Phase 4 transport fixtures, target credentials, rollback evidence, and explicit release authorization before execution"
1014 }
1015 }))
1016}
1017
1018pub fn production_readiness_report(app_id: Option<&str>) -> Result<Value, AgentErrorKind> {
1020 let repo_root = default_repo_root()?;
1021 let normalized_app_id = app_id.map(normalize_app_id).transpose()?;
1022 #[cfg(not(target_os = "wasi"))]
1023 let media_artifacts = super::agent_media::local_media_artifact_report(&repo_root);
1024 #[cfg(target_os = "wasi")]
1025 let media_artifacts = serde_json::json!({
1026 "schema": "pharos.agent.media_artifacts.v1",
1027 "status": "unavailable_on_wasi"
1028 });
1029 let offline_stt_ready = media_artifacts
1030 .pointer("/capabilities/offline_rust_stt")
1031 .and_then(Value::as_bool)
1032 .unwrap_or(false);
1033 let scanned_pdf_ocr_rust_ready = media_artifacts
1034 .pointer("/capabilities/scanned_pdf_ocr_rust")
1035 .and_then(Value::as_bool)
1036 .unwrap_or(false);
1037 let riva_speech_ready = media_artifacts
1038 .pointer("/capabilities/nvidia_riva_speech")
1039 .and_then(Value::as_bool)
1040 .unwrap_or(false);
1041 let checks = vec![
1042 readiness_check(
1043 "builtin_offline_speech_to_text",
1044 if offline_stt_ready {
1045 "available"
1046 } else {
1047 "blocked"
1048 },
1049 vec![
1050 readiness_prerequisite(
1051 "platform speech helper available",
1052 media_artifacts
1053 .pointer("/features/agent_stt_rust")
1054 .and_then(Value::as_bool)
1055 .unwrap_or(false),
1056 ),
1057 readiness_prerequisite(
1058 "workspace-local OxiWhisper GGML model artifact",
1059 media_artifacts
1060 .pointer("/artifacts/rust_stt_model")
1061 .and_then(Value::as_str)
1062 .is_some(),
1063 ),
1064 readiness_prerequisite(
1065 "Rust audio decoder and Rust-native STT adapter enabled",
1066 offline_stt_ready,
1067 ),
1068 ],
1069 ),
1070 readiness_check(
1071 "nvidia_riva_speech",
1072 if riva_speech_ready {
1073 "available_with_config"
1074 } else {
1075 "blocked"
1076 },
1077 vec![
1078 readiness_prerequisite(
1079 "Rust-native Riva client artifact",
1080 media_artifacts
1081 .pointer("/artifacts/riva_client_manifest")
1082 .and_then(Value::as_str)
1083 .is_some(),
1084 ),
1085 readiness_prerequisite(
1086 "configured Riva endpoint",
1087 media_artifacts
1088 .pointer("/artifacts/riva_endpoint_configured")
1089 .and_then(Value::as_bool)
1090 .unwrap_or(false),
1091 ),
1092 readiness_prerequisite(
1093 "Riva execution adapter implemented",
1094 media_artifacts
1095 .pointer("/features/agent_riva_rust")
1096 .and_then(Value::as_bool)
1097 .unwrap_or(false),
1098 ),
1099 readiness_prerequisite("Rust-native local integration fixture", riva_speech_ready),
1100 ],
1101 ),
1102 readiness_check(
1103 "ocr_scanned_pdf_extraction",
1104 if scanned_pdf_ocr_rust_ready {
1105 "available_with_config"
1106 } else {
1107 "blocked"
1108 },
1109 vec![
1110 readiness_prerequisite(
1111 "platform scanned-PDF helper available",
1112 media_artifacts
1113 .pointer("/features/agent_pdf_ocr_rust")
1114 .and_then(Value::as_bool)
1115 .unwrap_or(false),
1116 ),
1117 readiness_prerequisite(
1118 "workspace-local OCRS detection model",
1119 media_artifacts
1120 .pointer("/artifacts/ocrs_detection_model")
1121 .and_then(Value::as_str)
1122 .is_some(),
1123 ),
1124 readiness_prerequisite(
1125 "workspace-local OCRS recognition model",
1126 media_artifacts
1127 .pointer("/artifacts/ocrs_recognition_model")
1128 .and_then(Value::as_str)
1129 .is_some(),
1130 ),
1131 readiness_prerequisite(
1132 "bounded Rust-native scanned-PDF OCR adapter",
1133 scanned_pdf_ocr_rust_ready,
1134 ),
1135 readiness_prerequisite("extractable-text PDF parser", true),
1136 readiness_prerequisite(
1137 "Rust-native PDF rasterizer/image extractor",
1138 media_artifacts
1139 .pointer("/artifacts/rust_pdf_rasterizer_manifest")
1140 .and_then(Value::as_str)
1141 .is_some(),
1142 ),
1143 ],
1144 ),
1145 readiness_check(
1146 "live_external_integration_delivery",
1147 "available_with_config",
1148 vec![
1149 readiness_prerequisite("built app integrations contract", true),
1150 readiness_prerequisite("state path", true),
1151 readiness_prerequisite("configured adapter env refs", true),
1152 ],
1153 ),
1154 readiness_check(
1155 "scheduled_job_execution",
1156 "available",
1157 vec![
1158 readiness_prerequisite("SQLite app DB", true),
1159 readiness_prerequisite("jobs contract/migrations", true),
1160 readiness_prerequisite("idempotent execution proof", true),
1161 ],
1162 ),
1163 readiness_check(
1164 "backup_restore_execution",
1165 "available",
1166 vec![
1167 readiness_prerequisite("SQLite DB", true),
1168 readiness_prerequisite("state path", true),
1169 readiness_prerequisite("backup output/input directory", true),
1170 ],
1171 ),
1172 readiness_check(
1173 "target_deployment_execution",
1174 "preflight_only",
1175 vec![
1176 readiness_prerequisite("built app artifact", true),
1177 readiness_prerequisite("declared target", true),
1178 readiness_prerequisite("Phase 4 remote transport fixture and credentials", false),
1179 ],
1180 ),
1181 ];
1182 Ok(serde_json::json!({
1183 "schema": "pharos.agent.production_readiness.v1",
1184 "app_id": normalized_app_id,
1185 "repo_root": repo_root.to_string_lossy(),
1186 "status": readiness_rollup_status(&checks),
1187 "local_detection": {
1188 "media_artifacts": media_artifacts
1189 },
1190 "checks": checks,
1191 "policy": "Hard capabilities must fail closed until their local engine, model, credential, or target transport prerequisite is present and tested."
1192 }))
1193}
1194
1195fn readiness_check(capability_id: &str, status: &str, prerequisites: Vec<Value>) -> Value {
1196 serde_json::json!({
1197 "capability_id": capability_id,
1198 "status": status,
1199 "missing_or_required": prerequisites,
1200 })
1201}
1202
1203fn readiness_prerequisite(name: &str, present: bool) -> Value {
1204 serde_json::json!({"name": name, "present": present})
1205}
1206
1207fn readiness_rollup_status(checks: &[Value]) -> &'static str {
1208 if checks
1209 .iter()
1210 .any(|check| check.get("status").and_then(Value::as_str) == Some("blocked"))
1211 {
1212 "blocked_on_external_artifacts"
1213 } else if checks
1214 .iter()
1215 .any(|check| check.get("status").and_then(Value::as_str) == Some("preflight_only"))
1216 {
1217 "preflight_only"
1218 } else {
1219 "available"
1220 }
1221}
1222
1223fn default_repo_root() -> Result<PathBuf, AgentErrorKind> {
1225 let cwd = std::env::current_dir().map_err(|_| AgentErrorKind::InvalidPath)?;
1226 let mut current = Some(cwd.as_path());
1227 while let Some(path) = current {
1228 if path.join("apps").is_dir() && path.join("scripts").is_dir() {
1229 return path.canonicalize().map_err(|_| AgentErrorKind::InvalidPath);
1230 }
1231 current = path.parent();
1232 }
1233 Err(AgentErrorKind::InvalidPath)
1234}
1235
1236fn json_rpc_success<T: Serialize>(id: String, data: T) -> String {
1237 pharos_runtime_architect::json_rpc::json_rpc_success(id, data)
1238}
1239
1240fn json_rpc_failure(id: Option<String>, kind: AgentErrorKind) -> String {
1242 pharos_runtime_architect::json_rpc::json_rpc_failure(id, kind)
1243}
1244
1245pub fn parse_json_rpc_request(input: &str) -> Result<JsonRpcRequest, AgentErrorKind> {
1247 pharos_runtime_architect::json_rpc::parse_json_rpc_request(input)
1248}
1249
1250pub fn validate_json_rpc_request(request: &JsonRpcRequest) -> Result<(), AgentErrorKind> {
1252 pharos_runtime_architect::json_rpc::validate_json_rpc_request(request)
1253}
1254
1255pub fn parse_target_profile(input: &str) -> Result<TargetProfile, AgentErrorKind> {
1257 let profile: TargetProfile =
1258 serde_json::from_str(input).map_err(|_| AgentErrorKind::InvalidJson)?;
1259 validate_target_profile(&profile)?;
1260 Ok(profile)
1261}
1262
1263pub fn validate_target_profile(profile: &TargetProfile) -> Result<(), AgentErrorKind> {
1265 if profile.schema != "pharos.agent.target_profile.v1" {
1266 return Err(AgentErrorKind::InvalidEnvelope);
1267 }
1268 normalize_app_id(&profile.target_id)?;
1269 if profile.allowed_app_ids.is_empty() {
1270 return Err(AgentErrorKind::PolicyViolation);
1271 }
1272 for app_id in &profile.allowed_app_ids {
1273 normalize_app_id(app_id)?;
1274 }
1275 for secret_ref in &profile.secret_refs {
1276 validate_secret_ref(secret_ref)?;
1277 if secret_token_like(secret_ref) {
1278 return Err(AgentErrorKind::SecretRedacted);
1279 }
1280 }
1281 match (profile.transport, profile.artifact_kind) {
1282 (TargetTransport::SshDebian, ArtifactKind::NativePackage)
1283 | (TargetTransport::OciRegistry, ArtifactKind::OciService)
1284 | (TargetTransport::Kubernetes, ArtifactKind::OciService) => {}
1285 _ => return Err(AgentErrorKind::PolicyViolation),
1286 }
1287 if profile.environment == TargetEnvironment::Production && profile.secret_refs.is_empty() {
1288 return Err(AgentErrorKind::PolicyViolation);
1289 }
1290 Ok(())
1291}
1292
1293pub fn check_product_knowledge(repo_root: &Path) -> Result<String, i32> {
1295 pharos_runtime_architect::product_knowledge::check_product_knowledge(repo_root)
1296}
1297
1298pub fn check_written_product_knowledge(
1300 repo_root: &Path,
1301 output_root: &Path,
1302) -> Result<String, i32> {
1303 pharos_runtime_architect::product_knowledge::check_written_product_knowledge(
1304 repo_root,
1305 output_root,
1306 )
1307}
1308
1309pub fn write_product_knowledge(repo_root: &Path, output_root: &Path) -> Result<String, i32> {
1311 pharos_runtime_architect::product_knowledge::write_product_knowledge(repo_root, output_root)
1312}
1313
1314pub fn agent_sync_report(
1316 repo_root: &Path,
1317 output_root: &Path,
1318 check_only: bool,
1319 changed_paths: &[String],
1320 requested_features: &[String],
1321) -> Result<String, i32> {
1322 pharos_runtime_architect::product_knowledge::agent_sync_report(
1323 repo_root,
1324 output_root,
1325 check_only,
1326 changed_paths,
1327 requested_features,
1328 )
1329}
1330
1331pub fn product_knowledge_outputs(repo_root: &Path) -> Result<Vec<(&'static str, Value)>, i32> {
1332 pharos_runtime_architect::product_knowledge::product_knowledge_outputs(repo_root)
1333}
1334
1335#[cfg(test)]
1336fn validate_product_knowledge_documents(
1338 repo_root: &Path,
1339 outputs: &[(&'static str, Value)],
1340) -> Result<Value, i32> {
1341 pharos_runtime_architect::product_knowledge::validate_product_knowledge_documents(
1342 repo_root, outputs,
1343 )
1344}
1345
1346#[cfg(test)]
1347fn knowledge_source_files(repo_root: &Path) -> Vec<String> {
1349 pharos_runtime_architect::product_knowledge::knowledge_source_files(repo_root)
1350}
1351
1352#[cfg(test)]
1353fn source_fingerprint_with_overrides(
1355 repo_root: &Path,
1356 source_files: &[String],
1357 overrides: &BTreeMap<String, Vec<u8>>,
1358) -> Result<String, i32> {
1359 pharos_runtime_architect::product_knowledge::source_fingerprint_with_overrides(
1360 repo_root,
1361 source_files,
1362 overrides,
1363 )
1364}
1365
1366#[cfg(test)]
1367fn write_json_output(path: &Path, value: &Value) -> Result<(), i32> {
1369 let text = serde_json::to_string_pretty(value).map_err(|_| 70)?;
1370 fs::write(path, format!("{text}\n")).map_err(|_| 73)
1371}
1372
1373pub fn target_registry_projection(repo_root: &Path) -> Result<String, i32> {
1375 pharos_runtime_architect::product_knowledge::target_registry_projection(repo_root)
1376}
1377
1378pub fn target_registry_list_text(repo_root: &Path) -> Result<String, i32> {
1380 pharos_runtime_architect::product_knowledge::target_registry_list_text(repo_root)
1381}
1382
1383pub fn host_detection_report(repo_root: &Path, os: &str, arch: &str) -> Result<String, i32> {
1385 pharos_runtime_architect::product_knowledge::host_detection_report(repo_root, os, arch)
1386}
1387
1388pub fn write_baseline_inventory(repo_root: &Path, output_root: &Path) -> Result<String, i32> {
1390 pharos_runtime_architect::product_knowledge::write_baseline_inventory(repo_root, output_root)
1391}
1392
1393pub fn baseline_inventory_value(repo_root: &Path) -> Result<Value, i32> {
1395 pharos_runtime_architect::product_knowledge::baseline_inventory_value(repo_root)
1396}
1397
1398pub fn active_cli_verbs() -> Vec<Value> {
1400 pharos_runtime_architect::product_knowledge::active_cli_verbs()
1401}
1402
1403pub fn runtime_report_identifiers() -> Vec<Value> {
1405 pharos_runtime_architect::product_knowledge::runtime_report_identifiers()
1406}
1407
1408fn repo_relative_path(repo_root: &Path, path: &Path) -> String {
1409 pharos_runtime_architect::product_knowledge::repo_relative_path(repo_root, path)
1410}
1411
1412#[cfg(test)]
1413fn read_json_file(path: &Path) -> Result<Value, i32> {
1414 pharos_runtime_architect::product_knowledge::read_json_file(path)
1415}
1416
1417#[cfg(test)]
1418fn read_target_registry(repo_root: &Path) -> Result<Value, i32> {
1419 pharos_runtime_architect::product_knowledge::read_target_registry(repo_root)
1420}
1421
1422#[cfg(test)]
1423fn registry_targets(registry: &Value) -> Result<Vec<Value>, i32> {
1424 pharos_runtime_architect::product_knowledge::registry_targets(registry)
1425}
1426
1427#[cfg(test)]
1428mod tests {
1429 use super::*;
1430 use std::time::{SystemTime, UNIX_EPOCH};
1431
1432 fn repo_root() -> PathBuf {
1434 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1435 }
1436
1437 fn bridge_test_lock() -> std::sync::MutexGuard<'static, ()> {
1438 pharos_runtime_architect::lifecycle_validation::architect_current_dir_lock()
1439 .lock()
1440 .expect("bridge test lock")
1441 }
1442
1443 #[test]
1444 fn lifecycle_build_accepts_generated_workspace_outside_repository() {
1445 let _guard = bridge_test_lock();
1446 let root = temp_root("lifecycle-outside-repo");
1447 pharos_runtime_architect::scaffold::scaffold_app("outside_repo", &root.join("apps"))
1448 .expect("scaffold generated app");
1449 assert!(!root.join("scripts").exists());
1450 let original_dir = std::env::current_dir().expect("original directory");
1451 std::env::set_current_dir(&root).expect("generated workspace directory");
1452 let results = ["build", "verify", "doctor"].map(|verb| {
1453 (
1454 verb,
1455 run_lifecycle_app_tool(
1456 verb,
1457 "outside_repo",
1458 Some("apps/outside_repo"),
1459 Some("dist"),
1460 None,
1461 ),
1462 )
1463 });
1464 std::env::set_current_dir(original_dir).expect("restore directory");
1465 for (verb, result) in results {
1466 assert!(result.is_ok(), "generated workspace {verb}: {result:?}");
1467 }
1468 }
1469
1470 fn temp_root(label: &str) -> PathBuf {
1472 let nonce = SystemTime::now()
1473 .duration_since(UNIX_EPOCH)
1474 .expect("clock")
1475 .as_nanos();
1476 let path = std::env::temp_dir().join(format!(
1477 "pharos-agent-{label}-{}-{nonce}",
1478 std::process::id()
1479 ));
1480 fs::create_dir_all(&path).expect("temp root");
1481 path
1482 }
1483
1484 fn mutated_product_outputs<F>(
1486 outputs: &[(&'static str, Value)],
1487 target_name: &str,
1488 mut mutate: F,
1489 ) -> Vec<(&'static str, Value)>
1490 where
1491 F: FnMut(&mut Value),
1492 {
1493 outputs
1494 .iter()
1495 .map(|(name, value)| {
1496 let mut cloned = value.clone();
1497 if *name == target_name {
1498 mutate(&mut cloned);
1499 }
1500 (*name, cloned)
1501 })
1502 .collect()
1503 }
1504
1505 fn assert_maturity_failure(
1507 repo: &Path,
1508 outputs: &[(&'static str, Value)],
1509 expected_check: &str,
1510 ) {
1511 let report = validate_product_knowledge_documents(repo, outputs).expect("failure report");
1512 assert_eq!(report.get("status").and_then(Value::as_str), Some("failed"));
1513 assert!(
1514 report
1515 .pointer("/maturity/failed_checks")
1516 .and_then(Value::as_array)
1517 .expect("failed checks")
1518 .iter()
1519 .any(|value| value.as_str() == Some(expected_check)),
1520 "missing diagnostic failure for {expected_check}: {report}"
1521 );
1522 }
1523
1524 #[test]
1525 fn json_rpc_plans_inspects_and_applies_app_change() {
1527 let _bridge_test_guard = bridge_test_lock();
1528 let root = temp_root("rpc-transaction");
1529 fs::create_dir_all(root.join("apps")).expect("apps");
1530 fs::create_dir_all(root.join("scripts")).expect("scripts");
1531 pharos_runtime_architect::scaffold::scaffold_app("agent_tx", &root.join("apps"))
1532 .expect("scaffold");
1533 let original_dir = std::env::current_dir().expect("current dir");
1534 std::env::set_current_dir(&root).expect("chdir");
1535 let plan_request = serde_json::json!({
1536 "jsonrpc": "2.0",
1537 "id": "req-plan",
1538 "method": "pharos.plan_app_change",
1539 "params": {
1540 "app_id": "agent_tx",
1541 "file_patches": [{
1542 "path": "apps/agent_tx/data/features/agent-note.yml",
1543 "expected_sha256": null,
1544 "content": "contract_version: \"1.0\"\nfeature_id: agent_note\n"
1545 }]
1546 }
1547 })
1548 .to_string();
1549 let plan_response = execute_json_rpc(&plan_request);
1550 let plan_value: Value = serde_json::from_str(&plan_response).expect("plan response");
1551 let digest = plan_value
1552 .pointer("/result/data/plan_digest")
1553 .and_then(Value::as_str)
1554 .expect("digest")
1555 .to_owned();
1556 let inspect_response = execute_json_rpc(
1557 &serde_json::json!({
1558 "jsonrpc": "2.0",
1559 "id": "req-inspect",
1560 "method": "pharos.inspect_change",
1561 "params": {"app_id": "agent_tx", "change_digest": digest}
1562 })
1563 .to_string(),
1564 );
1565 let inspect_value: Value = serde_json::from_str(&inspect_response).expect("inspect");
1566 assert_eq!(
1567 inspect_value
1568 .pointer("/result/data/files/0/path")
1569 .and_then(Value::as_str),
1570 Some("apps/agent_tx/data/features/agent-note.yml")
1571 );
1572 let apply_response = execute_json_rpc(
1573 &serde_json::json!({
1574 "jsonrpc": "2.0",
1575 "id": "req-apply",
1576 "method": "pharos.apply_app_change",
1577 "params": {"app_id": "agent_tx", "plan_digest": digest}
1578 })
1579 .to_string(),
1580 );
1581 std::env::set_current_dir(original_dir).expect("restore dir");
1582 let apply_value: Value = serde_json::from_str(&apply_response).expect("apply");
1583 assert_eq!(
1584 apply_value
1585 .pointer("/result/data/status")
1586 .and_then(Value::as_str),
1587 Some("applied")
1588 );
1589 assert!(
1590 root.join("apps/agent_tx/data/features/agent-note.yml")
1591 .is_file()
1592 );
1593 }
1594
1595 #[test]
1596 fn synthesize_app_model_uses_existing_app_as_semantic_archetype() {
1598 let _bridge_test_guard = bridge_test_lock();
1599 let synthesis = synthesize_builtin_app_model(
1600 "todo_clone",
1601 "Create a todo webapp similar to todo_list",
1602 Some("todo_list"),
1603 )
1604 .expect("synthesis");
1605 assert_eq!(synthesis.model.archetype_id, "todo_list");
1606 assert_eq!(synthesis.model.entities[0].id, "task");
1607 }
1608
1609 #[test]
1610 fn plan_app_change_accepts_generated_crud_and_static_page_fixtures() {
1612 let _bridge_test_guard = bridge_test_lock();
1613 let root = temp_root("rpc-patterns");
1614 fs::create_dir_all(root.join("apps")).expect("apps");
1615 fs::create_dir_all(root.join("scripts")).expect("scripts");
1616 pharos_runtime_architect::scaffold::scaffold_app("agent_patterns", &root.join("apps"))
1617 .expect("scaffold");
1618 let original_dir = std::env::current_dir().expect("current dir");
1619 std::env::set_current_dir(&root).expect("chdir");
1620 let plan = plan_app_change(
1621 "agent_patterns",
1622 vec![
1623 FilePatch {
1624 encoding: Default::default(),
1625 path: "apps/agent_patterns/templates/generated_admin/item_admin.html"
1626 .to_owned(),
1627 expected_sha256: None,
1628 content: "<section data-generated-surface=\"crud\">{title}</section>\n"
1629 .to_owned(),
1630 },
1631 FilePatch {
1632 encoding: Default::default(),
1633 path: "apps/agent_patterns/templates/pages/about.html".to_owned(),
1634 expected_sha256: None,
1635 content: "<main><h1>About</h1><p>{summary}</p></main>\n".to_owned(),
1636 },
1637 ],
1638 )
1639 .expect("plan");
1640 let inspected = inspect_change_plan("agent_patterns", &plan.plan_digest).expect("inspect");
1641 assert_eq!(
1642 inspected.pointer("/files/0/path").and_then(Value::as_str),
1643 Some("apps/agent_patterns/templates/generated_admin/item_admin.html")
1644 );
1645 apply_app_change("agent_patterns", &plan.plan_digest).expect("apply");
1646 std::env::set_current_dir(original_dir).expect("restore dir");
1647 assert!(
1648 root.join("apps/agent_patterns/templates/generated_admin/item_admin.html")
1649 .is_file()
1650 );
1651 assert!(
1652 root.join("apps/agent_patterns/templates/pages/about.html")
1653 .is_file()
1654 );
1655 }
1656
1657 #[test]
1658 fn plan_app_change_rejects_root_replacement() {
1660 let _bridge_test_guard = bridge_test_lock();
1661 let root = temp_root("rpc-root-reject");
1662 fs::create_dir_all(root.join("apps")).expect("apps");
1663 fs::create_dir_all(root.join("scripts")).expect("scripts");
1664 pharos_runtime_architect::scaffold::scaffold_app("agent_tx", &root.join("apps"))
1665 .expect("scaffold");
1666 let original_dir = std::env::current_dir().expect("current dir");
1667 std::env::set_current_dir(&root).expect("chdir");
1668 let rejected = plan_app_change(
1669 "agent_tx",
1670 vec![FilePatch {
1671 encoding: Default::default(),
1672 path: "apps/agent_tx".to_owned(),
1673 expected_sha256: None,
1674 content: "replace root".to_owned(),
1675 }],
1676 );
1677 std::env::set_current_dir(original_dir).expect("restore dir");
1678 assert_eq!(rejected.err(), Some(AgentErrorKind::InvalidPath));
1679 }
1680
1681 #[test]
1682 fn apply_app_change_rolls_back_after_injected_stale_file() {
1684 let _bridge_test_guard = bridge_test_lock();
1685 let root = temp_root("rpc-rollback");
1686 fs::create_dir_all(root.join("apps")).expect("apps");
1687 fs::create_dir_all(root.join("scripts")).expect("scripts");
1688 pharos_runtime_architect::scaffold::scaffold_app("agent_tx", &root.join("apps"))
1689 .expect("scaffold");
1690 let first_path = root.join("apps/agent_tx/data/features/agent-note.yml");
1691 let readme_path = root.join("apps/agent_tx/README.md");
1692 let original_readme = fs::read_to_string(&readme_path).expect("readme");
1693 let original_dir = std::env::current_dir().expect("current dir");
1694 std::env::set_current_dir(&root).expect("chdir");
1695 let plan = plan_app_change(
1696 "agent_tx",
1697 vec![
1698 FilePatch {
1699 encoding: Default::default(),
1700 path: "apps/agent_tx/data/features/agent-note.yml".to_owned(),
1701 expected_sha256: None,
1702 content: "contract_version: \"1.0\"\nfeature_id: agent_note\n".to_owned(),
1703 },
1704 FilePatch {
1705 encoding: Default::default(),
1706 path: "apps/agent_tx/README.md".to_owned(),
1707 expected_sha256: Some("0".repeat(64)),
1708 content: "# Replaced\n".to_owned(),
1709 },
1710 ],
1711 )
1712 .expect("plan");
1713 let applied = apply_app_change("agent_tx", &plan.plan_digest);
1714 std::env::set_current_dir(original_dir).expect("restore dir");
1715 assert_eq!(applied.err(), Some(AgentErrorKind::StalePlan));
1716 assert!(!first_path.exists());
1717 assert_eq!(
1718 fs::read_to_string(readme_path).expect("readme"),
1719 original_readme
1720 );
1721 }
1722
1723 #[test]
1724 fn plan_app_change_rejects_out_of_app_patch() {
1726 let _bridge_test_guard = bridge_test_lock();
1727 let root = temp_root("rpc-transaction-reject");
1728 fs::create_dir_all(root.join("apps")).expect("apps");
1729 fs::create_dir_all(root.join("scripts")).expect("scripts");
1730 pharos_runtime_architect::scaffold::scaffold_app("agent_tx", &root.join("apps"))
1731 .expect("scaffold");
1732 let original_dir = std::env::current_dir().expect("current dir");
1733 std::env::set_current_dir(&root).expect("chdir");
1734 let rejected = plan_app_change(
1735 "agent_tx",
1736 vec![FilePatch {
1737 encoding: Default::default(),
1738 path: "apps/other_app/data/features/bad.yml".to_owned(),
1739 expected_sha256: None,
1740 content: "feature_id: bad\n".to_owned(),
1741 }],
1742 );
1743 std::env::set_current_dir(original_dir).expect("restore dir");
1744 assert_eq!(rejected.err(), Some(AgentErrorKind::InvalidPath));
1745 }
1746
1747 #[test]
1748 fn json_rpc_reads_existing_app_model() {
1750 let _bridge_test_guard = bridge_test_lock();
1751 let response = execute_json_rpc(
1752 r#"{"jsonrpc":"2.0","id":"req-read","method":"pharos.read_app_model","params":{"app_id":"todo_list"}}"#,
1753 );
1754 let value: Value = serde_json::from_str(&response).expect("response");
1755 assert_eq!(
1756 value.pointer("/result/data/app_id").and_then(Value::as_str),
1757 Some("todo_list")
1758 );
1759 assert!(
1760 value
1761 .pointer("/result/data/manifest/asset_keys")
1762 .and_then(Value::as_array)
1763 .is_some_and(|keys| keys.iter().any(|key| key.as_str() == Some("static_dir")))
1764 );
1765 assert!(
1766 value
1767 .pointer("/result/data/manifest/contract_keys")
1768 .and_then(Value::as_array)
1769 .is_some_and(|keys| !keys.is_empty())
1770 );
1771 }
1772
1773 #[test]
1774 fn json_rpc_scaffolds_app_with_repo_relative_output() {
1776 let _bridge_test_guard = bridge_test_lock();
1777 let root = temp_root("rpc-scaffold");
1778 fs::create_dir_all(root.join("apps")).expect("apps");
1779 fs::create_dir_all(root.join("scripts")).expect("scripts");
1780 fs::create_dir_all(root.join("tmp")).expect("tmp");
1781 let original_dir = std::env::current_dir().expect("current dir");
1782 std::env::set_current_dir(&root).expect("chdir");
1783 let response = execute_json_rpc(
1784 r#"{"jsonrpc":"2.0","id":"req-scaffold","method":"pharos.scaffold_app","params":{"app_id":"agent_scaffolded","output_root":"tmp"}}"#,
1785 );
1786 std::env::set_current_dir(original_dir).expect("restore dir");
1787 let value: Value = serde_json::from_str(&response).expect("response");
1788 assert_eq!(
1789 value.pointer("/result/data/status").and_then(Value::as_str),
1790 Some("created")
1791 );
1792 assert_eq!(
1793 value
1794 .pointer("/result/data/app_root")
1795 .and_then(Value::as_str),
1796 Some("tmp/agent_scaffolded")
1797 );
1798 assert!(root.join("tmp/agent_scaffolded/pharos.app.yml").is_file());
1799
1800 let original_dir = std::env::current_dir().expect("current dir");
1801 std::env::set_current_dir(&root).expect("chdir");
1802 let rejected = execute_json_rpc(
1803 r#"{"jsonrpc":"2.0","id":"req-artifact","method":"pharos.scaffold_app","params":{"app_id":"bad_output","output_root":"target/agent"}}"#,
1804 );
1805 std::env::set_current_dir(original_dir).expect("restore dir");
1806 let rejected_value: Value = serde_json::from_str(&rejected).expect("rejected");
1807 assert_eq!(
1808 rejected_value
1809 .pointer("/error/message")
1810 .and_then(Value::as_str),
1811 Some("policy_violation")
1812 );
1813 }
1814
1815 #[test]
1816 fn json_rpc_executes_builtin_app_model_synthesis() {
1818 let _bridge_test_guard = bridge_test_lock();
1819 let response = execute_json_rpc(
1820 r#"{"jsonrpc":"2.0","id":"req-1","method":"pharos.synthesize_app_model","params":{"app_id":"starter_builder","requirement":"Create a todo webapp for tasks","provider_id":"builtin"}}"#,
1821 );
1822 let value: Value = serde_json::from_str(&response).expect("response");
1823 assert_eq!(value.get("jsonrpc").and_then(Value::as_str), Some("2.0"));
1824 assert_eq!(
1825 value
1826 .pointer("/result/data/provider_id")
1827 .and_then(Value::as_str),
1828 Some("builtin")
1829 );
1830 assert_eq!(
1831 value
1832 .pointer("/result/data/model/entities/0/id")
1833 .and_then(Value::as_str),
1834 Some("task")
1835 );
1836 let rejected = execute_json_rpc(
1837 r#"{"jsonrpc":"2.0","id":"req-2","method":"pharos.synthesize_app_model","params":{"app_id":"starter_builder","requirement":"Create app","provider_id":"cloud"}}"#,
1838 );
1839 let rejected_value: Value = serde_json::from_str(&rejected).expect("rejected");
1840 assert_eq!(
1841 rejected_value
1842 .pointer("/error/message")
1843 .and_then(Value::as_str),
1844 Some("policy_violation")
1845 );
1846 }
1847
1848 #[test]
1849 fn builtin_app_model_synthesis_creates_baseline_model() {
1851 let _bridge_test_guard = bridge_test_lock();
1852 let synthesis = synthesize_builtin_app_model(
1853 "starter_builder",
1854 "Create a todo webapp for tracking tasks",
1855 None,
1856 )
1857 .expect("synthesis");
1858 assert_eq!(synthesis.provider_id, "builtin");
1859 assert_eq!(synthesis.model.app_id, "starter_builder");
1860 assert_eq!(synthesis.model.entities[0].id, "task");
1861 assert!(
1862 synthesis
1863 .model
1864 .capabilities
1865 .contains(&"record_workflow".to_owned())
1866 );
1867 assert!(
1868 synthesis
1869 .model
1870 .capabilities
1871 .contains(&"dynamic_app_persistence".to_owned())
1872 );
1873 assert!(
1874 !synthesis
1875 .model
1876 .capabilities
1877 .contains(&"sqlite_runtime_state".to_owned())
1878 );
1879 assert!(synthesis.model.navigation.contains(&"Tasks".to_owned()));
1880 assert!(
1881 synthesis.model.ui_archetypes.iter().any(|archetype| {
1882 archetype.archetype_id == "form.compact_labeled_control_row.v1"
1883 && archetype.ui_type == "form_layout"
1884 && archetype
1885 .css_tokens
1886 .iter()
1887 .any(|token| token == "form-grid" || token == "field")
1888 && archetype
1889 .source_examples
1890 .iter()
1891 .any(|source| source.starts_with("apps/"))
1892 }),
1893 "record-note synthesis should select compact form archetype from app examples"
1894 );
1895 assert!(
1896 synthesis.model.ui_archetypes.iter().any(|archetype| {
1897 archetype.archetype_id == "card.record_grid_with_empty_state.v1"
1898 }),
1899 "record-note synthesis should select record grid archetype"
1900 );
1901 assert!(
1902 synthesis
1903 .model
1904 .knowledge_sources
1905 .contains(&"docs/reference/library-reference.md".to_owned())
1906 );
1907 assert!(
1908 synthesis
1909 .model
1910 .knowledge_sources
1911 .contains(&"docs/reference/ui-styling-guide.md".to_owned())
1912 );
1913 assert!(
1914 synthesis
1915 .model
1916 .knowledge_sources
1917 .contains(&"target/agent/runtime-map.json#rustdoc".to_owned())
1918 );
1919 assert!(
1920 synthesis
1921 .model
1922 .acceptance_criteria
1923 .iter()
1924 .any(|criterion| criterion.id == "route_home_loads"
1925 && criterion.category == "route"
1926 && criterion.required)
1927 );
1928 assert!(
1929 synthesis
1930 .model
1931 .acceptance_criteria
1932 .iter()
1933 .any(|criterion| criterion.id == "task_form_submits"
1934 && criterion.category == "interaction")
1935 );
1936 assert_eq!(
1937 synthesis.model.routes[0].template,
1938 "templates/pages/home.html"
1939 );
1940 let tabbed = synthesize_builtin_app_model(
1941 "hello_tabs",
1942 "make a dynamic hellow world spa with 2 tabs in the nav and flashing green orange text",
1943 None,
1944 )
1945 .expect("tabbed synthesis");
1946 assert!(tabbed.model.capabilities.contains(&"tabbed_spa".to_owned()));
1947 assert!(
1948 tabbed
1949 .model
1950 .capabilities
1951 .contains(&"animated_visual_state".to_owned())
1952 );
1953 assert!(
1954 !tabbed
1955 .model
1956 .capabilities
1957 .contains(&"record_workflow".to_owned())
1958 );
1959 assert!(tabbed.model.actions.is_empty());
1960 assert_eq!(
1961 tabbed.model.navigation,
1962 vec!["Home".to_owned(), "Tab 1".to_owned(), "Tab 2".to_owned()]
1963 );
1964 assert!(
1965 tabbed
1966 .model
1967 .ui_archetypes
1968 .iter()
1969 .any(|archetype| archetype.archetype_id == "panel.tabbed_spa_content.v1")
1970 );
1971 let catalog = synthesize_builtin_app_model(
1972 "catalog_app",
1973 "Create a commerce catalog webapp for products with SKU and price",
1974 None,
1975 )
1976 .expect("catalog synthesis");
1977 assert_eq!(catalog.model.app_family_id, "commerce_catalog");
1978 assert_eq!(catalog.model.entities[0].id, "product");
1979 assert!(catalog.model.entities[0].fields.contains(&"sku".to_owned()));
1980 assert!(
1981 catalog.model.entities[0]
1982 .fields
1983 .contains(&"amount".to_owned())
1984 );
1985 assert!(
1986 catalog
1987 .model
1988 .knowledge_sources
1989 .contains(&"apps/commerce_spa/pharos.app.yml".to_owned())
1990 );
1991 assert!(catalog.model.ui_archetypes.iter().any(|archetype| {
1992 archetype.archetype_id == "card.product_media_purchase.v1"
1993 && archetype.selection_rule == "preferred"
1994 }));
1995 let booking = synthesize_builtin_app_model(
1996 "booking_app",
1997 "Create a booking scheduler with appointment time and customer contact",
1998 None,
1999 )
2000 .expect("booking synthesis");
2001 assert_eq!(booking.model.app_family_id, "booking_scheduler");
2002 assert_eq!(booking.model.entities[0].id, "booking");
2003 assert!(
2004 booking.model.entities[0]
2005 .fields
2006 .contains(&"scheduled_at".to_owned())
2007 );
2008 assert!(
2009 booking.model.entities[0]
2010 .fields
2011 .contains(&"contact".to_owned())
2012 );
2013 assert!(
2014 booking
2015 .model
2016 .knowledge_sources
2017 .contains(&"apps/ucal/pharos.app.yml".to_owned())
2018 );
2019 assert!(booking.model.ui_archetypes.iter().any(|archetype| {
2020 archetype.archetype_id == "card.record_grid_with_empty_state.v1"
2021 && archetype.selection_rule == "booking list or schedule card grid"
2022 }));
2023 let dashboard = synthesize_builtin_app_model(
2024 "metrics_app",
2025 "Create a dashboard webapp for metrics and KPI reports",
2026 None,
2027 )
2028 .expect("dashboard synthesis");
2029 assert_eq!(dashboard.model.app_family_id, "dashboard");
2030 assert!(
2031 dashboard
2032 .model
2033 .capabilities
2034 .contains(&"dashboard_metrics".to_owned())
2035 );
2036 assert!(dashboard.model.navigation.contains(&"Metrics".to_owned()));
2037 assert!(dashboard.model.ui_archetypes.iter().any(|archetype| {
2038 archetype.archetype_id == "card.metric_stat_grid.v1"
2039 && archetype.selection_rule == "preferred"
2040 }));
2041 assert!(dashboard.model.ui_archetypes.iter().any(|archetype| {
2042 archetype.archetype_id == "form.editable_select_option_manager.v1"
2043 && archetype.selection_rule.contains("KPI categories")
2044 }));
2045 for field in [
2046 "category",
2047 "metric_type",
2048 "target_value",
2049 "trend",
2050 "scheduled_at",
2051 ] {
2052 assert!(
2053 dashboard.model.entities[0]
2054 .fields
2055 .contains(&field.to_owned()),
2056 "missing dashboard field {field}"
2057 );
2058 }
2059 assert!(
2060 !dashboard.model.entities[0]
2061 .fields
2062 .contains(&"period".to_owned())
2063 );
2064 let dashboard_patches =
2065 builtin_authoring_patches(&dashboard.model).expect("dashboard patches");
2066 let dashboard_home = dashboard_patches
2067 .iter()
2068 .find(|patch| patch.path.ends_with("templates/home.body.html"))
2069 .map(|patch| patch.content.as_str())
2070 .expect("dashboard home template");
2071 assert!(
2072 dashboard_home.contains(r#"name="category""#)
2073 && dashboard_home.contains("Financial Health")
2074 );
2075 assert!(
2076 dashboard_home.contains(r#"name="metric_type""#)
2077 && dashboard_home.contains("Business KPI")
2078 );
2079 assert!(
2080 dashboard_home.contains(r#"name="target_value""#)
2081 && dashboard_home.contains(r#"max="9999""#)
2082 );
2083 assert!(
2084 dashboard_home.contains(r#"class="form-line kpi-line kpi-line--top top_row""#)
2085 && dashboard_home.contains(r#"name="scheduled_at""#)
2086 && dashboard_home.contains(r#"line-cell line-cell--at""#)
2087 );
2088 assert!(
2089 dashboard_home.contains("icon-action-button--submit")
2090 && dashboard_home.contains("Add metric")
2091 );
2092 let dashboard_iteration = synthesize_builtin_app_model(
2093 "dashboard_app",
2094 r#"reorder top row so that Metric Type is followed by Category and then Metric or work item
2095
2096Existing generated app context for same output_app_id. Preserve existing app behavior unless the new request explicitly replaces it, and apply this request as an iteration on the existing app.
2097--- README.md ---
2098Dashboard App is a Pharos KPI dashboard with selectable product/app performance and business intelligence metrics. Architect resolved these Pharos capabilities from the brief: app family dashboard, dashboard metrics.
2099--- templates/home.body.html ---
2100<main class="starter-shell architect-built family-dashboard" data-ui-archetypes="card.metric_stat_grid.v1 form.editable_select_option_manager.v1"></main>"#,
2101 None,
2102 )
2103 .expect("dashboard iteration synthesis");
2104 assert_eq!(dashboard_iteration.model.app_family_id, "dashboard");
2105 assert_eq!(dashboard_iteration.model.entities[0].id, "metric");
2106 assert_eq!(dashboard_iteration.model.actions[0].id, "create_metric");
2107 assert!(
2108 dashboard_iteration
2109 .model
2110 .ui_archetypes
2111 .iter()
2112 .any(|archetype| { archetype.archetype_id.contains("metric_stat_grid") })
2113 );
2114
2115 let iteration = synthesize_builtin_app_model(
2116 "worker_notes",
2117 "Add priority to the existing generated notes app and make the same output_app_id build CRUD existing records.
2118
2119Existing generated app context for same output_app_id. Preserve existing app behavior unless the new request explicitly replaces it, and apply this request as an iteration on the existing app.
2120--- data/contracts/entities.yml ---
2121contract_version: \"1.0\"
2122entities:
2123- entity_id: note
2124 table: note
2125 identity_field: id
2126 fields:
2127 - {field_id: id}
2128 - {field_id: title}
2129 - {field_id: customer_phone}
2130 - {field_id: note}
2131 - {field_id: created_at}
2132--- README.md ---
2133Existing worker notes app.
2134",
2135 None,
2136 )
2137 .expect("iteration synthesis");
2138 assert_eq!(iteration.model.entities[0].id, "note");
2139 for field in ["customer_phone", "priority"] {
2140 assert!(
2141 iteration.model.entities[0]
2142 .fields
2143 .contains(&field.to_owned()),
2144 "same-app iteration did not preserve/add field {field}"
2145 );
2146 }
2147 for action_id in ["create_note", "update_note", "delete_note"] {
2148 assert!(
2149 iteration
2150 .model
2151 .actions
2152 .iter()
2153 .any(|action| action.id == action_id),
2154 "same-app iteration missing action {action_id}"
2155 );
2156 }
2157 assert!(
2158 iteration
2159 .model
2160 .capabilities
2161 .contains(&"existing_entity_iteration".to_owned())
2162 );
2163 assert!(
2164 iteration
2165 .model
2166 .capabilities
2167 .contains(&"crud_record_workflow".to_owned())
2168 );
2169 let iteration_patches =
2170 builtin_authoring_patches(&iteration.model).expect("iteration patches");
2171 let feature = iteration_patches
2172 .iter()
2173 .find(|patch| patch.path.ends_with("data/features/starter.yml"))
2174 .map(|patch| patch.content.as_str())
2175 .expect("iteration feature contract");
2176 assert!(feature.contains("update_note:") && feature.contains("path: /items/update"));
2177 assert!(feature.contains("delete_note:") && feature.contains("path: /items/delete"));
2178 assert!(feature.contains("customer_phone: input.customer_phone"));
2179 let card = iteration_patches
2180 .iter()
2181 .find(|patch| patch.path.ends_with("templates/fragments/item_card.html"))
2182 .map(|patch| patch.content.as_str())
2183 .expect("iteration card fragment");
2184 assert!(card.contains(r#"pharos-action="update_note""#));
2185 assert!(card.contains(r#"pharos-action="delete_note""#));
2186 let field_crud_iteration = synthesize_builtin_app_model(
2187 "worker_notes",
2188 "Rename field customer_phone to phone, add field account tier, and remove field legacy_code from the existing generated notes app.
2189
2190Existing generated app context for same output_app_id. Preserve existing app behavior unless the new request explicitly replaces it, and apply this request as an iteration on the existing app.
2191--- data/contracts/entities.yml ---
2192contract_version: \"1.0\"
2193entities:
2194- entity_id: note
2195 table: note
2196 identity_field: id
2197 fields:
2198 - {field_id: id}
2199 - {field_id: title}
2200 - {field_id: customer_phone}
2201 - {field_id: legacy_code}
2202 - {field_id: note}
2203 - {field_id: created_at}
2204--- README.md ---
2205Existing worker notes app.
2206",
2207 None,
2208 )
2209 .expect("field CRUD iteration synthesis");
2210 let field_ids = &field_crud_iteration.model.entities[0].fields;
2211 assert!(
2212 field_ids.contains(&"phone".to_owned()),
2213 "rename target missing: {field_ids:?}"
2214 );
2215 assert!(
2216 field_ids.contains(&"account_tier".to_owned()),
2217 "added field missing: {field_ids:?}"
2218 );
2219 assert!(
2220 !field_ids.contains(&"customer_phone".to_owned()),
2221 "renamed field was not removed: {field_ids:?}"
2222 );
2223 assert!(
2224 !field_ids.contains(&"legacy_code".to_owned()),
2225 "removed field survived: {field_ids:?}"
2226 );
2227 let field_crud_patches =
2228 builtin_authoring_patches(&field_crud_iteration.model).expect("field CRUD patches");
2229 let field_crud_entities = field_crud_patches
2230 .iter()
2231 .find(|patch| patch.path.ends_with("data/contracts/entities.yml"))
2232 .map(|patch| patch.content.as_str())
2233 .expect("field CRUD entities contract");
2234 assert!(field_crud_entities.contains("field_id: phone"));
2235 assert!(field_crud_entities.contains("field_id: account_tier"));
2236 assert!(!field_crud_entities.contains("field_id: customer_phone"));
2237 assert!(!field_crud_entities.contains("field_id: legacy_code"));
2238 let crm = synthesize_builtin_app_model(
2239 "crm_app",
2240 "Create a CRM webapp for leads, client contacts, deal value, and follow-up next steps",
2241 None,
2242 )
2243 .expect("crm synthesis");
2244 assert_eq!(crm.model.app_family_id, "crm_pipeline");
2245 assert_eq!(crm.model.entities[0].id, "lead");
2246 for field in ["company", "contact", "email", "next_step", "amount"] {
2247 assert!(crm.model.entities[0].fields.contains(&field.to_owned()));
2248 }
2249 assert!(
2250 crm.model
2251 .capabilities
2252 .contains(&"contact_management".to_owned())
2253 );
2254 assert!(crm.model.navigation.contains(&"Pipeline".to_owned()));
2255 assert!(crm.model.ui_archetypes.iter().any(|archetype| {
2256 archetype.archetype_id == "card.record_grid_with_empty_state.v1"
2257 && archetype.selection_rule == "lead cards with contact and next-step metadata"
2258 }));
2259 let crm_patches = builtin_authoring_patches(&crm.model).expect("crm patches");
2260 assert!(crm_patches.iter().any(|patch| {
2261 patch.path.ends_with("templates/home.body.html")
2262 && patch.content.contains("family-crm")
2263 && patch.content.contains("name=\"next_step\"")
2264 }));
2265 assert!(crm_patches.iter().any(|patch| {
2266 patch.path.ends_with("templates/fragments/item_card.html")
2267 && patch.content.contains("Company {company}")
2268 && patch.content.contains("Next Step {next_step}")
2269 }));
2270 let invoice = synthesize_builtin_app_model(
2271 "invoice_tracker",
2272 "Create an invoice tracker for customer billing with invoice number, amount, due date, and paid status",
2273 None,
2274 )
2275 .expect("invoice synthesis");
2276 for field in ["invoice_number", "customer", "amount", "due_date"] {
2277 assert!(
2278 invoice.model.entities[0].fields.contains(&field.to_owned()),
2279 "missing invoice field {field}"
2280 );
2281 }
2282 let invoice_patches = builtin_authoring_patches(&invoice.model).expect("invoice patches");
2283 let invoice_patch = |suffix: &str| {
2284 invoice_patches
2285 .iter()
2286 .find(|patch| patch.path.ends_with(suffix))
2287 .map(|patch| patch.content.as_str())
2288 .expect(suffix)
2289 };
2290 assert!(invoice_patch("templates/home.body.html").contains("name=\"invoice_number\""));
2291 assert!(invoice_patch("templates/home.body.html").contains("name=\"due_date\""));
2292 assert!(
2293 invoice_patch("templates/fragments/item_card.html")
2294 .contains("Invoice Number {invoice_number}")
2295 );
2296 let contract: pharos_runtime_database::contract::AppDataContract = serde_json::from_value(
2297 pharos_runtime_compiler::database_yaml::parse(invoice_patch(
2298 "data/contracts/database.yml",
2299 ))
2300 .unwrap(),
2301 )
2302 .expect("generated logical database contract");
2303 assert!(
2304 contract
2305 .schema
2306 .entities
2307 .iter()
2308 .any(|entity| entity.fields.iter().any(|field| {
2309 field.name == "invoice_number"
2310 && field.kind == pharos_runtime_database::operations::FieldType::Text
2311 && !field.nullable
2312 && field.default
2313 == Some(pharos_runtime_database::operations::FieldDefault::Literal(
2314 serde_json::json!(""),
2315 ))
2316 }))
2317 );
2318
2319 let support = synthesize_builtin_app_model(
2320 "support_queue",
2321 "Create a support ticket queue with requester, category, priority, assignee, and customer phone",
2322 None,
2323 )
2324 .expect("support synthesis");
2325 for field in [
2326 "requester",
2327 "priority",
2328 "category",
2329 "assignee",
2330 "customer",
2331 "phone",
2332 ] {
2333 assert!(
2334 support.model.entities[0].fields.contains(&field.to_owned()),
2335 "missing support field {field}"
2336 );
2337 }
2338 let support_patches = builtin_authoring_patches(&support.model).expect("support patches");
2339 let support_home = support_patches
2340 .iter()
2341 .find(|patch| patch.path.ends_with("templates/home.body.html"))
2342 .map(|patch| patch.content.as_str())
2343 .expect("support home template");
2344 assert!(support_home.contains("name=\"requester\""));
2345 assert!(support_home.contains("name=\"assignee\""));
2346
2347 let ops = synthesize_builtin_app_model(
2348 "ops_ready_app",
2349 "Create an authenticated admin app with roles, permissions, webhook integration, email notifications, scheduled reminder jobs, backup restore policy, and production deployment target behavior",
2350 None,
2351 )
2352 .expect("operational synthesis");
2353 for capability in [
2354 "authenticated_routes",
2355 "role_policy_authoring",
2356 "permission_capabilities",
2357 "integration_webhook",
2358 "notification_email",
2359 "scheduled_jobs",
2360 "backup_restore_policy",
2361 "target_deployment_plan",
2362 ] {
2363 assert!(
2364 ops.model.capabilities.contains(&capability.to_owned()),
2365 "missing capability {capability}"
2366 );
2367 }
2368 assert!(
2369 ops.model
2370 .validation_expectations
2371 .iter()
2372 .any(|value| { value.contains("least-privilege roles") })
2373 );
2374 assert!(
2375 ops.model
2376 .validation_expectations
2377 .iter()
2378 .any(|value| { value.contains("env secret refs") })
2379 );
2380 assert!(
2381 ops.model
2382 .validation_expectations
2383 .iter()
2384 .any(|value| { value.contains("scheduled jobs") })
2385 );
2386 assert!(
2387 ops.model
2388 .validation_expectations
2389 .iter()
2390 .any(|value| { value.contains("backup restore policy excludes vault secrets") })
2391 );
2392 assert!(
2393 ops.model
2394 .validation_expectations
2395 .iter()
2396 .any(|value| { value.contains("target deployment plan is fail-closed") })
2397 );
2398 let ops_patches = builtin_authoring_patches(&ops.model).expect("operational patches");
2399 let patch = |suffix: &str| {
2400 ops_patches
2401 .iter()
2402 .find(|patch| patch.path.ends_with(suffix))
2403 .map(|patch| patch.content.as_str())
2404 .expect(suffix)
2405 };
2406 let routes = pharos_runtime_core::yaml::parse_yaml_text(patch("data/contracts/routes.yml"))
2407 .expect("authenticated route contract");
2408 let routes = routes["routes"].as_array().expect("executable routes");
2409 assert!(routes.iter().any(|route| route["action_id"] == "login"));
2410 assert!(routes.iter().any(|route| route["action_id"] == "logout"));
2411 assert!(patch("data/contracts/routes.yml").contains("planned_routes:"));
2412 assert!(patch("data/contracts/routes.yml").contains("kind: page"));
2413 assert!(patch("data/contracts/routes.yml").contains("request_hooks: [form_body_required]"));
2414 assert!(patch("data/contracts/actions.yml").contains("effect_policy:"));
2415 assert!(patch("data/contracts/actions.yml").contains("deny_undeclared: true"));
2416 assert!(patch("data/contracts/actions.yml").contains("planned_actions:"));
2417 assert!(patch("data/contracts/surfaces.yml").contains("surface_kind: html_page"));
2418 assert!(patch("data/contracts/surfaces.yml").contains("surface_kind: form"));
2419 assert!(
2420 patch("data/contracts/generated_surfaces.yml")
2421 .contains("surface_kind: generated_admin")
2422 );
2423 assert!(patch("data/contracts/generated_surfaces.yml").contains("review_required: true"));
2424 assert!(patch("data/contracts/policies.yml").contains("role: owner"));
2425 assert!(patch("data/contracts/policies.yml").contains("manage_settings"));
2426 assert!(patch("data/contracts/auth.yml").contains("session_backend: shared_pharos_auth"));
2427 assert!(patch("data/contracts/auth.yml").contains("mutation_policy: manage_records"));
2428 assert!(patch("data/features/starter.yml").contains("policy: authenticated"));
2429 assert!(patch("data/features/starter.yml").contains("policy: manage_records"));
2430 assert!(
2431 patch("templates/home.body.html").contains("Generated operational capability review")
2432 );
2433 assert!(patch("templates/home.body.html").contains("Auth, roles, and permissions"));
2434 assert!(patch("templates/home.body.html").contains("Webhook and email adapters"));
2435 assert!(patch("templates/home.body.html").contains("Local review job plan"));
2436 assert!(patch("templates/home.body.html").contains("Verified restore policy"));
2437 assert!(patch("templates/home.body.html").contains("Deployment gate"));
2438 assert!(patch("README.md").contains("docs/reference/proving-apps-and-validation.md"));
2439 assert!(
2440 patch("README.md").contains("Target and deployment behavior remains release-closed")
2441 );
2442 assert!(patch("/ACCEPTANCE.md").contains("Acceptance criteria for Ops Ready App"));
2443 assert!(patch("/ACCEPTANCE.md").contains("auth_policy_exercised"));
2444 assert!(patch("/ACCEPTANCE.md").contains("deployment_release_closed"));
2445 assert!(
2446 patch("BUILD-EVIDENCE.json")
2447 .contains("\"schema\": \"pharos.generated_app.build_evidence.v1\"")
2448 );
2449 assert!(patch("BUILD-EVIDENCE.json").contains("\"acceptance_criteria\""));
2450 assert!(patch("BUILD-EVIDENCE.json").contains("\"export_cleanliness\""));
2451 assert!(patch("data/contracts/integrations.yml").contains("adapter_kind: http_json"));
2452 assert!(
2453 patch("data/contracts/integrations.yml").contains("adapter_kind: notification_email")
2454 );
2455 assert!(
2456 patch("data/contracts/integrations.yml").contains("PHAROS_OPS_READY_APP_WEBHOOK_TOKEN")
2457 );
2458 assert!(!patch("data/contracts/integrations.yml").contains("api_key"));
2459 assert!(patch("data/contracts/jobs.yml").contains("job_kind: scheduled"));
2460 assert!(
2461 patch("data/contracts/backup_restore.yml")
2462 .contains("excludes: [vault, media_quarantine, secrets]")
2463 );
2464 assert!(patch("data/contracts/targets.yml").contains("release_allowed: false"));
2465 assert!(
2466 patch("data/contracts/targets.yml")
2467 .contains("production_requires_local_green_gate: true")
2468 );
2469 assert!(patch("MANUAL-ACCEPTANCE.md").contains("Auth, roles, and permissions"));
2470 assert!(patch("MANUAL-ACCEPTANCE.md").contains("Integrations and email"));
2471 assert!(patch("MANUAL-ACCEPTANCE.md").contains("Scheduled jobs"));
2472 assert!(patch("MANUAL-ACCEPTANCE.md").contains("Backup and restore"));
2473 assert!(patch("MANUAL-ACCEPTANCE.md").contains("Target and deployment behavior"));
2474 assert!(patch("MANUAL-ACCEPTANCE.md").contains("PHAROS_OPS_READY_APP_WEBHOOK_TOKEN"));
2475 let composition = synthesize_builtin_app_model(
2476 "capability_composition_app",
2477 "Create an authenticated admin dashboard with generated admin CRUD surfaces, a UI library design system, role permissions, webhook API integration, email notifications, scheduled reminder jobs, backup restore policy, and deployment target controls",
2478 None,
2479 )
2480 .expect("capability composition synthesis");
2481 assert_eq!(composition.model.app_family_id, "dashboard");
2482 for capability in [
2483 "authenticated_routes",
2484 "role_policy_authoring",
2485 "permission_capabilities",
2486 "integration_webhook",
2487 "notification_email",
2488 "scheduled_jobs",
2489 "backup_restore_policy",
2490 "target_deployment_plan",
2491 "generated_surface_contract",
2492 "ui_lib_surface",
2493 ] {
2494 assert!(
2495 composition
2496 .model
2497 .capabilities
2498 .contains(&capability.to_owned()),
2499 "missing composition capability {capability}"
2500 );
2501 }
2502 let composition_patches =
2503 builtin_authoring_patches(&composition.model).expect("capability composition patches");
2504 let composition_patch = |suffix: &str| {
2505 composition_patches
2506 .iter()
2507 .find(|patch| patch.path.ends_with(suffix))
2508 .map(|patch| patch.content.as_str())
2509 .expect(suffix)
2510 };
2511 assert!(
2512 composition_patch("data/contracts/generated_surfaces.yml")
2513 .contains("surface_kind: generated_admin")
2514 );
2515 assert!(
2516 composition_patch("data/contracts/auth.yml")
2517 .contains("default_authenticated_role: viewer")
2518 );
2519 assert!(composition_patch("data/features/starter.yml").contains("policy: authenticated"));
2520 assert!(composition_patch("data/features/starter.yml").contains("policy: manage_records"));
2521 assert!(composition_patch("data/contracts/surfaces.yml").contains("surface_kind: ui_lib"));
2522 assert!(
2523 composition_patch("data/contracts/surfaces.yml")
2524 .contains("title: \"Capability Composition App UI library\"")
2525 );
2526 assert!(synthesis.app_model_digest.len() == 64);
2527 assert_eq!(
2528 app_model_digest(&synthesis.model).expect("digest"),
2529 synthesis.app_model_digest
2530 );
2531 let validation = &synthesis.model.validation_expectations;
2532 assert!(
2533 validation
2534 .iter()
2535 .any(|value| value.contains("authenticated navigation"))
2536 );
2537 assert!(
2538 validation
2539 .iter()
2540 .any(|value| value.contains("runtime fragment placeholder"))
2541 );
2542 assert!(
2543 validation
2544 .iter()
2545 .any(|value| value.contains("WCAG-readable"))
2546 );
2547 assert!(
2548 validation
2549 .iter()
2550 .any(|value| value.contains("seeded local credentials"))
2551 );
2552 assert!(
2553 validation
2554 .iter()
2555 .any(|value| value.contains("icon buttons"))
2556 );
2557 assert!(
2558 validation
2559 .iter()
2560 .any(|value| value.contains("modern standards consumed"))
2561 );
2562 assert!(
2563 validation
2564 .iter()
2565 .any(|value| value.contains("security controls consumed"))
2566 );
2567 assert!(synthesis.model.knowledge_sources.iter().any(|source| {
2568 source == "target/agent/app-model-reference.json#standards_references"
2569 }));
2570 assert!(
2571 validation
2572 .iter()
2573 .any(|value| value.contains("red disabled"))
2574 );
2575 assert!(
2576 validation
2577 .iter()
2578 .any(|value| value.contains("reusable UI patterns applied")
2579 && value.contains("htmx_targeted_actions")
2580 && value.contains("icon_button_navigation")
2581 && value.contains("card_grid_records"))
2582 );
2583 assert!(
2584 validation
2585 .iter()
2586 .any(|value| value.contains("typed UI archetypes selected")
2587 && value.contains("form.compact_labeled_control_row.v1"))
2588 );
2589 assert!(
2590 validation
2591 .iter()
2592 .any(|value| value.contains("reference docs consumed")
2593 && value.contains("library-reference.md")
2594 && value.contains("runtime-map.json#rustdoc"))
2595 );
2596 let mut dashboard_model = synthesize_builtin_app_model(
2597 "dashboard_crud",
2598 "Create a KPI dashboard app with metrics, category, metric type, target value, trend, and scheduled date",
2599 None,
2600 )
2601 .expect("dashboard crud synthesis")
2602 .model;
2603 let dashboard_entity_id = dashboard_model.entities[0].id.clone();
2604 dashboard_model.actions.push(AppActionModel {
2605 id: format!("update_{dashboard_entity_id}"),
2606 route_id: "home".to_owned(),
2607 intent: format!("update_{dashboard_entity_id}_record"),
2608 });
2609 dashboard_model.actions.push(AppActionModel {
2610 id: format!("delete_{dashboard_entity_id}"),
2611 route_id: "home".to_owned(),
2612 intent: format!("delete_{dashboard_entity_id}_record"),
2613 });
2614 let dashboard_patches =
2615 builtin_authoring_patches(&dashboard_model).expect("dashboard crud patches");
2616 let dashboard_feature = dashboard_patches
2617 .iter()
2618 .find(|patch| patch.path.ends_with("data/features/starter.yml"))
2619 .map(|patch| patch.content.as_str())
2620 .expect("dashboard feature contract");
2621 pharos_runtime_core::yaml::validate_unique_mapping_keys(dashboard_feature)
2622 .expect("generated CRUD feature YAML must not contain duplicate keys");
2623 assert!(dashboard_feature.contains(&format!(" update_{dashboard_entity_id}:")));
2624 assert!(dashboard_feature.contains(&format!(" delete_{dashboard_entity_id}:")));
2625 assert_eq!(
2626 dashboard_feature
2627 .matches(" title: input.title")
2628 .count(),
2629 2
2630 );
2631 assert_eq!(
2632 dashboard_feature
2633 .matches(" note: input.note")
2634 .count(),
2635 2
2636 );
2637
2638 let starter_patches =
2639 builtin_authoring_patches(&synthesis.model).expect("starter authoring patches");
2640 let starter_patch = |suffix: &str| {
2641 starter_patches
2642 .iter()
2643 .find(|patch| patch.path.ends_with(suffix))
2644 .map(|patch| patch.content.as_str())
2645 .expect(suffix)
2646 };
2647 assert!(starter_patch("/ACCEPTANCE.md").contains("task_persists_after_refresh"));
2648 assert!(starter_patch("BUILD-EVIDENCE.json").contains("\"app_id\": \"starter_builder\""));
2649 assert_eq!(
2650 synthesize_builtin_app_model("bad/app", "Create a todo webapp", None).err(),
2651 Some(AgentErrorKind::InvalidIdentifier)
2652 );
2653 assert_eq!(
2654 synthesize_builtin_app_model("starter", "api_key=secret", None).err(),
2655 Some(AgentErrorKind::SecretRedacted)
2656 );
2657 }
2658
2659 #[test]
2660 fn product_knowledge_outputs_are_deterministic_and_portable() {
2662 let _bridge_test_guard = bridge_test_lock();
2663 let repo = repo_root();
2664 let outputs = product_knowledge_outputs(&repo).expect("product knowledge");
2665 let names = outputs.iter().map(|(name, _)| *name).collect::<Vec<_>>();
2666 assert_eq!(
2667 names,
2668 vec![
2669 "pharos-agent-index.json",
2670 "app-model-reference.json",
2671 "archetype-inventory.json",
2672 "ui-catalog.json",
2673 "runtime-map.json",
2674 "cli-reference.json",
2675 "capability-ledger.json"
2676 ]
2677 );
2678 let serialized = serde_json::to_string(&outputs).expect("json");
2679 assert!(!serialized.contains(repo.to_string_lossy().as_ref()));
2680 assert!(!serialized.contains("/Users/"));
2681 assert!(!serialized.contains("sk-"));
2682 assert!(!serialized.contains("api_key="));
2683 assert!(!serialized.contains("token="));
2684 assert!(!serialized.contains("app.conf\""));
2685
2686 let app_model = outputs
2687 .iter()
2688 .find(|(name, _)| *name == "app-model-reference.json")
2689 .map(|(_, value)| value)
2690 .expect("app model");
2691 assert_eq!(
2692 app_model
2693 .pointer("/operating_primer/contract_first_mandate")
2694 .and_then(Value::as_str)
2695 .map(|value| value.contains("Start every generated webapp from the app model")),
2696 Some(true)
2697 );
2698 let learned_rules = app_model
2699 .get("learned_generation_rules")
2700 .and_then(Value::as_object)
2701 .expect("learned rules");
2702 for (key, expected) in [
2703 ("auth_navigation", "Anonymous users must not see"),
2704 ("template_fragments", "Do not leave unresolved placeholders"),
2705 ("color_contrast", "readable text/background contrast"),
2706 ("local_credentials", "Seeded development credentials"),
2707 ("bootstrap_icons", "vendor Bootstrap Icons locally"),
2708 ("icon_navigation", "standard button size"),
2709 (
2710 "settings_switches",
2711 "inactive labels in readable black text",
2712 ),
2713 ("yaml_authoring", "YAML 1.2.2-compatible"),
2714 ("json_authoring", "RFC 8259 and ECMA-404"),
2715 ("html_authoring", "WHATWG Living Standard"),
2716 ("css_authoring", "W3C CSS snapshot"),
2717 ("htmx_authoring", "CSRF coverage"),
2718 ("sql_authoring", "ISO/IEC 9075:2023"),
2719 ("rust_authoring", "Rust Reference"),
2720 ("toml_authoring", "TOML v1.0.0"),
2721 ("markdown_authoring", "CommonMark-compatible"),
2722 ("svg_authoring", "SVG 2"),
2723 ("security_compliance", "SOC 2 and ISO/IEC 27001"),
2724 ] {
2725 assert_eq!(
2726 learned_rules
2727 .get(key)
2728 .and_then(Value::as_str)
2729 .map(|value| value.contains(expected)),
2730 Some(true),
2731 "{key} should include {expected}"
2732 );
2733 }
2734 let standards_references = app_model
2735 .get("standards_references")
2736 .and_then(Value::as_array)
2737 .expect("standards references");
2738 for reference_id in [
2739 "yaml-1.2.2",
2740 "html-living-standard",
2741 "css-snapshot-2025",
2742 "htmx-security-basics-2024",
2743 "json-rfc-8259",
2744 "json-ecma-404",
2745 "sql-iso-iec-9075-2023",
2746 "rust-reference",
2747 "toml-1.0.0",
2748 "commonmark-0.31.2",
2749 "svg-2",
2750 "soc2-trust-services-criteria-2022",
2751 "iso-iec-27001-2022",
2752 ] {
2753 assert!(
2754 standards_references.iter().any(|reference| reference
2755 .get("reference_id")
2756 .and_then(Value::as_str)
2757 == Some(reference_id)),
2758 "missing standards reference {reference_id}"
2759 );
2760 }
2761 assert!(standards_references.iter().any(|reference| {
2762 reference.get("source_url").and_then(Value::as_str)
2763 == Some("https://www.w3.org/TR/css-2025/")
2764 && reference
2765 .get("builder_guidance")
2766 .and_then(Value::as_str)
2767 .is_some_and(|guidance| guidance.contains("modular app-owned CSS"))
2768 }));
2769 assert!(standards_references.iter().any(|reference| {
2770 reference.get("reference_id").and_then(Value::as_str)
2771 == Some("soc2-trust-services-criteria-2022")
2772 && reference
2773 .get("generation_controls")
2774 .and_then(Value::as_array)
2775 .is_some_and(|controls| {
2776 controls.iter().any(|control| {
2777 control
2778 .as_str()
2779 .is_some_and(|text| text.contains("hardcoded secrets"))
2780 })
2781 })
2782 }));
2783
2784 let runtime_map = outputs
2785 .iter()
2786 .find(|(name, _)| *name == "runtime-map.json")
2787 .map(|(_, value)| value)
2788 .expect("runtime map");
2789 assert_eq!(
2790 runtime_map
2791 .pointer("/builder_flow/template_owner")
2792 .and_then(Value::as_str)
2793 .map(|value| value.contains("templates/ owns page structure")),
2794 Some(true)
2795 );
2796 assert!(
2797 runtime_map
2798 .get("implemented_cli_surfaces")
2799 .and_then(Value::as_array)
2800 .expect("cli surfaces")
2801 .iter()
2802 .any(|value| value.get("verb").and_then(Value::as_str) == Some("build"))
2803 );
2804 assert_eq!(
2805 runtime_map
2806 .pointer("/execution_boundaries/wasi")
2807 .and_then(Value::as_str)
2808 .map(|value| value.contains("must not receive host filesystem")),
2809 Some(true)
2810 );
2811 assert!(
2812 runtime_map
2813 .get("operations_surfaces")
2814 .and_then(Value::as_array)
2815 .expect("ops surfaces")
2816 .iter()
2817 .any(|value| value.as_str() == Some("provenance"))
2818 );
2819 assert_eq!(
2820 runtime_map
2821 .pointer("/rustdoc_knowledge/usage")
2822 .and_then(Value::as_str)
2823 .map(|value| value.contains("runtime handlers")),
2824 Some(true)
2825 );
2826 let rustdoc_areas = runtime_map
2827 .pointer("/rustdoc_knowledge/knowledge_areas")
2828 .and_then(Value::as_array)
2829 .expect("rustdoc areas");
2830 for area_id in [
2831 "route_runtime",
2832 "action_runtime",
2833 "rendering_runtime",
2834 "storage_runtime",
2835 ] {
2836 assert!(
2837 rustdoc_areas.iter().any(|area| {
2838 area.get("area_id").and_then(Value::as_str) == Some(area_id)
2839 && area.get("status").and_then(Value::as_str) == Some("represented")
2840 && area
2841 .get("module_paths")
2842 .and_then(Value::as_array)
2843 .is_some_and(|paths| !paths.is_empty())
2844 }),
2845 "missing Rustdoc knowledge area {area_id}"
2846 );
2847 }
2848 assert!(
2849 app_model
2850 .pointer("/yaml_schema_catalog/contracts")
2851 .and_then(Value::as_array)
2852 .expect("contract schema")
2853 .iter()
2854 .any(|value| value.as_str() == Some("route_contract"))
2855 );
2856 assert!(
2857 app_model
2858 .get("required_outputs")
2859 .and_then(Value::as_array)
2860 .expect("required outputs")
2861 .iter()
2862 .any(|value| value.as_str() == Some("workflows"))
2863 );
2864 assert_eq!(
2865 app_model
2866 .pointer("/lifecycle_command_status/build_app")
2867 .and_then(Value::as_str),
2868 Some("implemented_generic_runtime_dispatch")
2869 );
2870 assert_eq!(
2871 app_model
2872 .pointer("/lifecycle_command_status/seed_app")
2873 .and_then(Value::as_str),
2874 Some("implemented_compatibility_alias")
2875 );
2876 assert!(
2877 app_model
2878 .pointer("/yaml_schema_catalog/assets")
2879 .and_then(Value::as_array)
2880 .expect("asset schema")
2881 .iter()
2882 .any(|value| value.as_str() == Some("runtime_media_dir"))
2883 );
2884 assert!(
2885 app_model
2886 .get("docs_as_contract")
2887 .and_then(Value::as_array)
2888 .expect("docs")
2889 .iter()
2890 .any(|value| value.get("path").and_then(Value::as_str)
2891 == Some("docs/contracts/yaml-authoring.md"))
2892 );
2893 assert!(
2894 app_model
2895 .get("docs_as_contract")
2896 .and_then(Value::as_array)
2897 .expect("docs")
2898 .iter()
2899 .any(|value| value.get("path").and_then(Value::as_str)
2900 == Some("docs/reference/library-reference.md"))
2901 );
2902 assert!(
2903 app_model
2904 .pointer("/operating_primer/knowledge_consumption_points")
2905 .and_then(Value::as_array)
2906 .expect("knowledge consumption")
2907 .iter()
2908 .any(|value| value.as_str() == Some("target/agent/runtime-map.json#rustdoc"))
2909 );
2910 let ui_catalog = outputs
2911 .iter()
2912 .find(|(name, _)| *name == "ui-catalog.json")
2913 .map(|(_, value)| value)
2914 .expect("ui catalog");
2915 assert!(
2916 ui_catalog
2917 .get("frontend_patterns")
2918 .and_then(Value::as_array)
2919 .expect("patterns")
2920 .iter()
2921 .any(|value| value.as_str() == Some("no_authored_html_in_rust"))
2922 );
2923 assert!(
2924 ui_catalog
2925 .get("source_app_count")
2926 .and_then(Value::as_u64)
2927 .is_some_and(|count| count >= 8)
2928 );
2929 assert!(
2930 ui_catalog
2931 .get("components")
2932 .and_then(Value::as_array)
2933 .expect("components")
2934 .iter()
2935 .any(|component| component.get("kind").and_then(Value::as_str) == Some("fragment"))
2936 );
2937 let typed_archetypes = ui_catalog
2938 .get("typed_archetypes")
2939 .and_then(Value::as_array)
2940 .expect("typed archetypes");
2941 for archetype_id in [
2942 "action.button_bar_semantic_colors.v1",
2943 "card.metric_stat_grid.v1",
2944 "card.product_media_purchase.v1",
2945 "card.record_grid_with_empty_state.v1",
2946 "console.task_status_timeline.v1",
2947 "empty_state.actionable_placeholder.v1",
2948 "form.compact_labeled_control_row.v1",
2949 "form.context_file_voice_input.v1",
2950 "form.editable_select_option_manager.v1",
2951 "hero.summary_action_panel.v1",
2952 "nav.icon_buttons_with_hover_labels.v1",
2953 "page.calendar_matrix.v1",
2954 "page.checkout_side_panel.v1",
2955 "page.documentation_shell.v1",
2956 "page.hero_grid_index.v1",
2957 "page.master_detail_workspace.v1",
2958 "page.settings_console.v1",
2959 "panel.tabbed_spa_content.v1",
2960 "settings.boolean_switch_fragment.v1",
2961 "shell.compact_app_frame.v1",
2962 "table.dense_admin_or_reference.v1",
2963 ] {
2964 assert!(
2965 typed_archetypes.iter().any(|archetype| {
2966 archetype.get("archetype_id").and_then(Value::as_str) == Some(archetype_id)
2967 && archetype
2968 .get("structure")
2969 .and_then(Value::as_array)
2970 .is_some_and(|items| !items.is_empty())
2971 && archetype
2972 .get("css_tokens")
2973 .and_then(Value::as_array)
2974 .is_some_and(|items| !items.is_empty())
2975 && archetype
2976 .get("preferred_source_examples")
2977 .and_then(Value::as_array)
2978 .is_some_and(|items| !items.is_empty())
2979 && archetype
2980 .get("anti_patterns")
2981 .and_then(Value::as_array)
2982 .is_some_and(|items| !items.is_empty())
2983 && archetype
2984 .get("family_selection_rules")
2985 .and_then(Value::as_object)
2986 .is_some()
2987 }),
2988 "missing typed UI archetype {archetype_id}"
2989 );
2990 }
2991 assert!(typed_archetypes.iter().any(|archetype| {
2992 archetype.get("archetype_id").and_then(Value::as_str)
2993 == Some("form.compact_labeled_control_row.v1")
2994 && archetype
2995 .get("anti_patterns")
2996 .and_then(Value::as_array)
2997 .is_some_and(|items| {
2998 items.iter().any(|item| {
2999 item.as_str()
3000 .is_some_and(|text| text.contains("label-to-control gaps"))
3001 })
3002 })
3003 }));
3004 assert!(typed_archetypes.iter().any(|archetype| {
3005 archetype.get("archetype_id").and_then(Value::as_str)
3006 == Some("card.product_media_purchase.v1")
3007 && archetype
3008 .pointer("/family_selection_rules/commerce_catalog")
3009 .and_then(Value::as_str)
3010 == Some("preferred")
3011 }));
3012
3013 let reusable_patterns = ui_catalog
3014 .get("reusable_patterns")
3015 .and_then(Value::as_array)
3016 .expect("reusable patterns");
3017 for pattern_id in [
3018 "app_shell_layout",
3019 "htmx_targeted_actions",
3020 "icon_button_navigation",
3021 "button_rows",
3022 "card_grid_records",
3023 "compact_settings_forms",
3024 "editable_select_option_managers",
3025 "page_calendar_matrix",
3026 "page_checkout_side_panel",
3027 "page_documentation_shell",
3028 "page_hero_grid_index",
3029 "page_master_detail_workspace",
3030 "page_settings_console",
3031 "setting_switches",
3032 ] {
3033 assert!(
3034 reusable_patterns.iter().any(|pattern| {
3035 pattern.get("pattern_id").and_then(Value::as_str) == Some(pattern_id)
3036 && pattern
3037 .get("source_examples")
3038 .and_then(Value::as_array)
3039 .is_some_and(|examples| !examples.is_empty())
3040 }),
3041 "missing reusable UI pattern {pattern_id}"
3042 );
3043 }
3044 let patterns_by_id = reusable_patterns
3045 .iter()
3046 .filter_map(|pattern| Some((pattern.get("pattern_id")?.as_str()?, pattern)))
3047 .collect::<BTreeMap<_, _>>();
3048 assert!(
3049 patterns_by_id["setting_switches"]
3050 .get("source_examples")
3051 .and_then(Value::as_array)
3052 .expect("switch examples")
3053 .iter()
3054 .any(|value| value
3055 .as_str()
3056 .is_some_and(|path| path.starts_with("apps/ucal")))
3057 );
3058 assert!(
3059 patterns_by_id["icon_button_navigation"]
3060 .get("source_examples")
3061 .and_then(Value::as_array)
3062 .expect("icon examples")
3063 .iter()
3064 .any(|value| value
3065 .as_str()
3066 .is_some_and(|path| path.starts_with("apps/dev_docs")))
3067 );
3068 assert!(
3069 patterns_by_id["card_grid_records"]
3070 .get("source_examples")
3071 .and_then(Value::as_array)
3072 .expect("card examples")
3073 .iter()
3074 .any(|value| value
3075 .as_str()
3076 .is_some_and(|path| path.starts_with("apps/commerce_spa")))
3077 );
3078 assert!(
3079 patterns_by_id["compact_settings_forms"]
3080 .get("source_examples")
3081 .and_then(Value::as_array)
3082 .expect("settings examples")
3083 .iter()
3084 .any(|value| value
3085 .as_str()
3086 .is_some_and(|path| path.starts_with("apps/architect")))
3087 );
3088
3089 let archetypes = outputs
3090 .iter()
3091 .find(|(name, _)| *name == "archetype-inventory.json")
3092 .map(|(_, value)| value)
3093 .expect("archetypes");
3094 let ucal = archetypes
3095 .get("apps")
3096 .and_then(Value::as_array)
3097 .expect("apps")
3098 .iter()
3099 .find(|app| app.get("app_id").and_then(Value::as_str) == Some("ucal"))
3100 .expect("ucal");
3101 assert_eq!(ucal.get("base_path").and_then(Value::as_str), Some("/ucal"));
3102 assert!(
3103 ucal.get("contract_families")
3104 .and_then(Value::as_array)
3105 .expect("contract families")
3106 .iter()
3107 .any(|value| value.as_str() == Some("form_contract"))
3108 );
3109
3110 let check_report =
3111 validate_product_knowledge_documents(&repo, &outputs).expect("check report");
3112 assert_eq!(
3113 check_report.get("status").and_then(Value::as_str),
3114 Some("verified")
3115 );
3116 assert_eq!(
3117 check_report
3118 .pointer("/maturity/schema")
3119 .and_then(Value::as_str),
3120 Some("pharos.agent.product_knowledge_maturity.v1")
3121 );
3122 assert_eq!(
3123 check_report
3124 .pointer("/maturity/status")
3125 .and_then(Value::as_str),
3126 Some("production_ready_local_knowledge_intake")
3127 );
3128 assert_eq!(
3129 check_report
3130 .pointer("/maturity/maturity_level")
3131 .and_then(Value::as_str),
3132 Some("deterministic_local_generation_prerequisite")
3133 );
3134 assert!(
3135 check_report
3136 .pointer("/maturity/source_app_count")
3137 .and_then(Value::as_u64)
3138 .is_some_and(|count| count >= 8)
3139 );
3140 assert!(
3141 check_report
3142 .pointer("/maturity/typed_archetype_count")
3143 .and_then(Value::as_u64)
3144 .is_some_and(|count| count >= 14)
3145 );
3146 assert!(
3147 check_report
3148 .pointer("/maturity/rustdoc_line_count")
3149 .and_then(Value::as_u64)
3150 .is_some_and(|count| count > 0)
3151 );
3152 assert!(
3153 check_report
3154 .pointer("/maturity/checks")
3155 .and_then(Value::as_array)
3156 .expect("maturity checks")
3157 .iter()
3158 .all(|check| check.get("status").and_then(Value::as_str) == Some("passed"))
3159 );
3160
3161 let ledger = outputs
3162 .iter()
3163 .find(|(name, _)| *name == "capability-ledger.json")
3164 .map(|(_, value)| value)
3165 .expect("capability ledger");
3166 assert_eq!(
3167 ledger.get("unresolved_count").and_then(Value::as_u64),
3168 Some(0)
3169 );
3170 assert!(
3171 ledger
3172 .get("records")
3173 .and_then(Value::as_array)
3174 .expect("ledger records")
3175 .iter()
3176 .any(
3177 |record| record.get("classification").and_then(Value::as_str)
3178 == Some("ui_interface")
3179 )
3180 );
3181
3182 let index = outputs
3183 .iter()
3184 .find(|(name, _)| *name == "pharos-agent-index.json")
3185 .map(|(_, value)| value)
3186 .expect("index");
3187 assert_eq!(
3188 index.get("schema").and_then(Value::as_str),
3189 Some("pharos.agent.index.v1")
3190 );
3191 assert!(
3192 index
3193 .get("apps")
3194 .and_then(Value::as_array)
3195 .expect("apps")
3196 .iter()
3197 .any(|app| app.get("app_id").and_then(Value::as_str) == Some("ucal"))
3198 );
3199 }
3200
3201 #[test]
3202 fn product_knowledge_maturity_reports_broken_inputs() {
3204 let _bridge_test_guard = bridge_test_lock();
3205 let repo = repo_root();
3206 let outputs = product_knowledge_outputs(&repo).expect("product knowledge");
3207
3208 let missing_library =
3209 mutated_product_outputs(&outputs, "app-model-reference.json", |value| {
3210 value
3211 .get_mut("docs_as_contract")
3212 .and_then(Value::as_array_mut)
3213 .expect("docs")
3214 .retain(|entry| {
3215 entry.get("path").and_then(Value::as_str)
3216 != Some("docs/reference/library-reference.md")
3217 });
3218 });
3219 assert_maturity_failure(&repo, &missing_library, "library_reference_consumed");
3220
3221 let missing_ui_guide =
3222 mutated_product_outputs(&outputs, "app-model-reference.json", |value| {
3223 value
3224 .get_mut("docs_as_contract")
3225 .and_then(Value::as_array_mut)
3226 .expect("docs")
3227 .retain(|entry| {
3228 entry.get("path").and_then(Value::as_str)
3229 != Some("docs/reference/ui-styling-guide.md")
3230 });
3231 });
3232 assert_maturity_failure(&repo, &missing_ui_guide, "ui_styling_guide_consumed");
3233
3234 let zero_rustdoc = mutated_product_outputs(&outputs, "runtime-map.json", |value| {
3235 for module in value
3236 .get_mut("modules")
3237 .and_then(Value::as_array_mut)
3238 .expect("modules")
3239 {
3240 module["rustdoc_line_count"] = serde_json::json!(0);
3241 }
3242 for area in value
3243 .pointer_mut("/rustdoc_knowledge/knowledge_areas")
3244 .and_then(Value::as_array_mut)
3245 .expect("areas")
3246 {
3247 area["status"] = serde_json::json!("missing");
3248 area["module_paths"] = serde_json::json!([]);
3249 }
3250 });
3251 assert_maturity_failure(&repo, &zero_rustdoc, "rustdoc_runtime_map_consumed");
3252 assert_maturity_failure(&repo, &zero_rustdoc, "rustdoc_runtime_areas_consumed");
3253
3254 let malformed_archetype = mutated_product_outputs(&outputs, "ui-catalog.json", |value| {
3255 let first = value
3256 .get_mut("typed_archetypes")
3257 .and_then(Value::as_array_mut)
3258 .and_then(|items| items.first_mut())
3259 .expect("first archetype");
3260 first["structure"] = serde_json::json!([]);
3261 });
3262 assert_maturity_failure(&repo, &malformed_archetype, "typed_ui_archetypes_consumed");
3263
3264 let unresolved_ledger =
3265 mutated_product_outputs(&outputs, "capability-ledger.json", |value| {
3266 value["unresolved_count"] = serde_json::json!(1);
3267 });
3268 assert_maturity_failure(&repo, &unresolved_ledger, "capability_ledger_resolved");
3269
3270 let missing_standards =
3271 mutated_product_outputs(&outputs, "app-model-reference.json", |value| {
3272 value
3273 .get_mut("standards_references")
3274 .and_then(Value::as_array_mut)
3275 .expect("standards")
3276 .retain(|entry| {
3277 entry.get("reference_id").and_then(Value::as_str)
3278 != Some("iso-iec-27001-2022")
3279 });
3280 });
3281 assert_maturity_failure(
3282 &repo,
3283 &missing_standards,
3284 "web_standards_references_consumed",
3285 );
3286 assert_maturity_failure(
3287 &repo,
3288 &missing_standards,
3289 "security_compliance_references_consumed",
3290 );
3291
3292 let malformed_standards =
3293 mutated_product_outputs(&outputs, "app-model-reference.json", |value| {
3294 let first = value
3295 .get_mut("standards_references")
3296 .and_then(Value::as_array_mut)
3297 .and_then(|items| items.first_mut())
3298 .expect("first standard");
3299 first["source_url"] = serde_json::json!("http://example.invalid/not-authoritative");
3300 });
3301 assert_maturity_failure(
3302 &repo,
3303 &malformed_standards,
3304 "web_standards_references_consumed",
3305 );
3306
3307 let missing_ui_archetypes =
3308 mutated_product_outputs(&outputs, "app-model-reference.json", |value| {
3309 value
3310 .get_mut("required_outputs")
3311 .and_then(Value::as_array_mut)
3312 .expect("required outputs")
3313 .retain(|entry| entry.as_str() != Some("ui_archetypes"));
3314 });
3315 assert_maturity_failure(
3316 &repo,
3317 &missing_ui_archetypes,
3318 "app_model_requires_ui_archetypes",
3319 );
3320 }
3321
3322 #[test]
3323 fn product_knowledge_detects_source_drift_and_stale_artifacts() {
3325 let _bridge_test_guard = bridge_test_lock();
3326 let repo = repo_root();
3327 let source_files = knowledge_source_files(&repo);
3328 let current =
3329 pharos_runtime_architect::product_knowledge::source_fingerprint(&repo, &source_files)
3330 .expect("current fingerprint");
3331 let mut overrides = BTreeMap::new();
3332 overrides.insert(
3333 "apps/architect/templates/settings.body.html".to_owned(),
3334 b"<section>changed architect settings layout</section>".to_vec(),
3335 );
3336 let changed_template = source_fingerprint_with_overrides(&repo, &source_files, &overrides)
3337 .expect("template drift");
3338 assert_ne!(current, changed_template);
3339 overrides.clear();
3340 overrides.insert(
3341 "apps/architect/static/css/modules/architect/03-app.css".to_owned(),
3342 b".knowledge-readiness-card{border-color:#147a3d}".to_vec(),
3343 );
3344 let changed_css =
3345 source_fingerprint_with_overrides(&repo, &source_files, &overrides).expect("css drift");
3346 assert_ne!(current, changed_css);
3347
3348 let output_dir = Path::new("target/agent-test-stale-knowledge");
3349 let _ = fs::remove_dir_all(repo.join(output_dir));
3350 write_product_knowledge(&repo, output_dir).expect("write knowledge");
3351 let catalog_path = repo.join(output_dir).join("ui-catalog.json");
3352 let mut catalog = read_json_file(&catalog_path).expect("catalog");
3353 catalog["source_fingerprint"] = serde_json::json!("stale-fingerprint");
3354 write_json_output(&catalog_path, &catalog).expect("stale catalog write");
3355 assert_eq!(check_written_product_knowledge(&repo, output_dir), Err(65));
3356 let _ = fs::remove_dir_all(repo.join(output_dir));
3357 }
3358
3359 #[test]
3360 fn agent_sync_report_scans_workflows_and_records_requested_feature_gaps() {
3362 let _bridge_test_guard = bridge_test_lock();
3363 let repo = repo_root();
3364 let report = agent_sync_report(
3365 &repo,
3366 Path::new("target/agent-test-sync"),
3367 true,
3368 &[
3369 "src/runtime/agent_bridge.rs".to_owned(),
3370 ".github/workflows/release.yml".to_owned(),
3371 "apps/ucal/pharos.app.yml".to_owned(),
3372 "apps/architect/pharos.app.yml".to_owned(),
3373 "containers/Dockerfile.runtime".to_owned(),
3374 "docs/reference/library-reference.md".to_owned(),
3375 "docs/reference/ui-styling-guide.md".to_owned(),
3376 "data/framework/agent/builtin-app/templates/home.body.html".to_owned(),
3377 ],
3378 &[
3379 "deploy app to configured target".to_owned(),
3380 "build app through existing lifecycle".to_owned(),
3381 ],
3382 )
3383 .expect("sync report");
3384 let value: Value = serde_json::from_str(&report).expect("sync json");
3385 assert_eq!(
3386 value
3387 .pointer("/workflow_scan/schema")
3388 .and_then(Value::as_str),
3389 Some("pharos.agent.workflow_scan.v1")
3390 );
3391 assert!(
3392 value
3393 .pointer("/workflow_scan/files")
3394 .and_then(Value::as_array)
3395 .expect("workflow files")
3396 .iter()
3397 .any(|file| file.get("path").and_then(Value::as_str)
3398 == Some(".github/workflows/release.yml"))
3399 );
3400 assert_eq!(
3401 value
3402 .pointer("/workflow_scan/automatic_hosted_triggers_enabled")
3403 .and_then(Value::as_bool),
3404 Some(false)
3405 );
3406 assert!(
3407 value
3408 .pointer("/workflow_scan/commented_disabled_triggers")
3409 .and_then(Value::as_array)
3410 .expect("commented triggers")
3411 .iter()
3412 .any(|trigger| trigger.as_str() == Some("push"))
3413 );
3414 assert!(
3415 value
3416 .pointer("/workflow_scan/future_release_targets")
3417 .and_then(Value::as_array)
3418 .expect("future targets")
3419 .iter()
3420 .any(|target| target.as_str() == Some("wasm32-wasi"))
3421 );
3422 let affected_capabilities = value
3423 .get("affected_capabilities")
3424 .and_then(Value::as_array)
3425 .expect("affected capabilities");
3426 assert!(affected_capabilities.iter().any(|entry| {
3427 entry.get("path").and_then(Value::as_str) == Some("containers/Dockerfile.runtime")
3428 && entry.get("classification").and_then(Value::as_str)
3429 == Some("deployment_persistence")
3430 && entry
3431 .get("required_proof")
3432 .and_then(Value::as_str)
3433 .is_some_and(|proof| proof.contains("artifact identity"))
3434 }));
3435 assert!(affected_capabilities.iter().any(|entry| {
3436 entry.get("path").and_then(Value::as_str) == Some("apps/architect/pharos.app.yml")
3437 && entry.get("classification").and_then(Value::as_str)
3438 == Some("deployment_persistence")
3439 && entry
3440 .get("required_proof")
3441 .and_then(Value::as_str)
3442 .is_some_and(|proof| proof.contains("runtime media"))
3443 }));
3444 assert!(affected_capabilities.iter().any(|entry| {
3445 entry.get("path").and_then(Value::as_str) == Some("docs/reference/library-reference.md")
3446 && entry.get("classification").and_then(Value::as_str)
3447 == Some("product_knowledge_reference")
3448 && entry
3449 .pointer("/knowledge_intake/input_class")
3450 .and_then(Value::as_str)
3451 == Some("declarative_surface_reference")
3452 && entry
3453 .pointer("/knowledge_intake/builder_impact")
3454 .and_then(Value::as_str)
3455 .is_some_and(|impact| impact.contains("Pharos logic"))
3456 }));
3457 assert!(affected_capabilities.iter().any(|entry| {
3458 entry.get("path").and_then(Value::as_str) == Some("docs/reference/ui-styling-guide.md")
3459 && entry
3460 .pointer("/knowledge_intake/input_class")
3461 .and_then(Value::as_str)
3462 == Some("ui_styling_reference")
3463 && entry
3464 .pointer("/knowledge_intake/affected_outputs")
3465 .and_then(Value::as_array)
3466 .expect("affected outputs")
3467 .iter()
3468 .any(|output| output.as_str() == Some("ui-catalog.json"))
3469 }));
3470 assert!(affected_capabilities.iter().any(|entry| {
3471 entry.get("path").and_then(Value::as_str)
3472 == Some("data/framework/agent/builtin-app/templates/home.body.html")
3473 && entry.get("classification").and_then(Value::as_str)
3474 == Some("builder_generation_template")
3475 && entry
3476 .pointer("/knowledge_intake/input_class")
3477 .and_then(Value::as_str)
3478 == Some("builtin_generation_template")
3479 }));
3480 assert!(
3481 affected_capabilities
3482 .iter()
3483 .find(|entry| {
3484 entry.get("path").and_then(Value::as_str) == Some("src/runtime/agent_bridge.rs")
3485 })
3486 .and_then(|entry| entry.pointer("/knowledge_intake/input_class"))
3487 .and_then(Value::as_str)
3488 == Some("rustdoc_runtime_source")
3489 );
3490 assert_eq!(
3491 value
3492 .pointer("/knowledge_intake_loop/schema")
3493 .and_then(Value::as_str),
3494 Some("pharos.agent.knowledge_intake_loop.v1")
3495 );
3496 assert!(
3497 value
3498 .pointer("/knowledge_intake_loop/changed_input_count")
3499 .and_then(Value::as_u64)
3500 .is_some_and(|count| count >= 4)
3501 );
3502 assert!(
3503 value
3504 .pointer("/knowledge_intake_loop/required_synthesis_proof")
3505 .and_then(Value::as_str)
3506 .is_some_and(|proof| proof.contains("scripts/check.sh agent surface"))
3507 );
3508 let gaps = value
3509 .get("gap_records")
3510 .and_then(Value::as_array)
3511 .expect("gap records");
3512 assert_eq!(gaps.len(), 1);
3513 assert_eq!(
3514 gaps[0].get("schema").and_then(Value::as_str),
3515 Some("pharos.agent.requested_feature_gap.v1")
3516 );
3517 assert_eq!(
3518 gaps[0].get("nearest_owner").and_then(Value::as_str),
3519 Some("src/runtime/ops_bridge.rs")
3520 );
3521 assert_eq!(
3522 value.get("unresolved_count").and_then(Value::as_u64),
3523 Some(1)
3524 );
3525 assert_eq!(
3526 value
3527 .pointer("/explanatory_prose/schema")
3528 .and_then(Value::as_str),
3529 Some("pharos.agent.sync_explanatory_prose.v1")
3530 );
3531 assert!(
3532 value
3533 .pointer("/explanatory_prose/entries")
3534 .and_then(Value::as_array)
3535 .expect("prose entries")
3536 .iter()
3537 .any(|entry| entry.get("kind").and_then(Value::as_str)
3538 == Some("requested_feature_gap"))
3539 );
3540 }
3541
3542 #[test]
3543 fn product_knowledge_writer_stays_repo_local() {
3545 let _bridge_test_guard = bridge_test_lock();
3546 let repo = repo_root();
3547 let output_dir = Path::new("target/agent-test-product-knowledge");
3548 let report = write_product_knowledge(&repo, output_dir).expect("write knowledge");
3549 let report_value: Value = serde_json::from_str(&report).expect("report json");
3550 let outputs = report_value
3551 .get("outputs")
3552 .and_then(Value::as_array)
3553 .expect("outputs");
3554 assert_eq!(outputs.len(), 7);
3555 for output in outputs {
3556 let path = output.get("path").and_then(Value::as_str).expect("path");
3557 assert!(path.starts_with("target/agent-test-product-knowledge/"));
3558 assert!(repo.join(path).is_file());
3559 }
3560 let outside = std::env::temp_dir().join(format!(
3561 "pharos-agent-product-outside-{}",
3562 std::process::id()
3563 ));
3564 assert_eq!(write_product_knowledge(&repo, &outside), Err(64));
3565 }
3566
3567 #[test]
3568 fn baseline_inventory_reflects_current_repo_surfaces() {
3570 let _bridge_test_guard = bridge_test_lock();
3571 let inventory = baseline_inventory_value(&repo_root()).expect("inventory");
3572 let apps = inventory
3573 .get("app_roots")
3574 .and_then(Value::as_array)
3575 .expect("apps");
3576 assert!(
3577 apps.iter()
3578 .any(|app| app.get("app_id").and_then(Value::as_str) == Some("ucal"))
3579 );
3580 assert!(
3581 apps.iter()
3582 .any(|app| app.get("app_id").and_then(Value::as_str) == Some("todo_list"))
3583 );
3584
3585 let registry = inventory.get("target_registry").expect("registry");
3586 assert_eq!(
3587 registry
3588 .get("declared_target_count")
3589 .and_then(Value::as_u64),
3590 registry.get("actual_target_count").and_then(Value::as_u64)
3591 );
3592 let registry_file = read_target_registry(&repo_root()).expect("registry file");
3593 let registry_targets = registry_targets(®istry_file).expect("registry targets");
3594 assert_eq!(
3595 registry.get("actual_target_count").and_then(Value::as_u64),
3596 Some(registry_targets.len() as u64)
3597 );
3598 }
3599
3600 #[cfg(all(feature = "sqlite", not(target_os = "wasi")))]
3601 #[test]
3602 fn json_rpc_executes_backup_and_restore_native_surfaces() {
3604 let _bridge_test_guard = bridge_test_lock();
3605 let root = temp_root("rpc-backup-restore");
3606 let app_root = root.join("apps/ops_app");
3607 fs::create_dir_all(app_root.join("data/contracts")).expect("contracts");
3608 fs::create_dir_all(root.join("tmp")).expect("data root");
3609 fs::write(
3610 app_root.join("pharos.app.yml"),
3611 "app_id: ops_app\ndatabase:\n migration_contract: data/contracts/database.json\n",
3612 )
3613 .expect("manifest");
3614 fs::write(
3615 app_root.join("app.conf"),
3616 format!(
3617 "db_backend=sqlite\ndb_path={}\nbackup_dir={}\n",
3618 root.join("tmp/ops.sqlite3").display(),
3619 root.join("tmp/backups").display()
3620 ),
3621 )
3622 .expect("app config");
3623 use pharos_runtime_database::operations::{
3624 Migration, Mutation, Predicate, Query, SchemaChange, Transaction, WriteValue,
3625 };
3626 let schema: pharos_runtime_database::operations::AppSchema =
3627 serde_json::from_value(serde_json::json!({"app_id":"ops_app", "entities":[{
3628 "name":"record", "identity":{"auto_integer":"id"}, "fields":[
3629 {"name":"id","kind":"integer","nullable":false,"constraints":[]},
3630 {"name":"title","kind":"text","nullable":false,"constraints":[]}
3631 ], "unique":[], "indexes":[], "conditional_indexes":[]
3632 }]}))
3633 .expect("logical schema");
3634 let changes: Vec<SchemaChange> = vec![
3635 SchemaChange::CreateEntity(schema.entities[0].clone()),
3636 SchemaChange::Seed {
3637 entity: "record".to_owned(),
3638 values: serde_json::json!({"id":1,"title":"original"})
3639 .as_object()
3640 .expect("record")
3641 .clone(),
3642 },
3643 ];
3644 let contract = pharos_runtime_database::contract::AppDataContract {
3645 schema,
3646 migrations: vec![Migration {
3647 app_id: "ops_app".to_owned(),
3648 id: "records".to_owned(),
3649 checksum: pharos_runtime_database::contract::migration_checksum(
3650 "ops_app", "records", &changes,
3651 )
3652 .expect("checksum"),
3653 changes,
3654 }],
3655 legacy_adoption: None,
3656 };
3657 fs::write(
3658 app_root.join("data/contracts/database.json"),
3659 serde_json::to_vec(&contract).expect("contract JSON"),
3660 )
3661 .expect("contract");
3662 let store = super::super::database::logical_app_store(&app_root, None, "ops_app")
3663 .expect("configured operation store");
3664 store
3665 .adapter
3666 .apply_contract(&store.config, &contract)
3667 .expect("logical seed");
3668 fs::write(root.join("tmp/state.json"), r#"{"items":[]}"#).expect("unrelated file");
3670 let original_dir = std::env::current_dir().expect("cwd");
3671 std::env::set_current_dir(&root).expect("chdir");
3672 let backup = execute_json_rpc(
3673 r#"{"jsonrpc":"2.0","id":"backup","method":"pharos.backup_app","params":{"app_id":"ops_app","app_dir":"apps/ops_app","output_dir":"tmp/backups"}}"#,
3674 );
3675 let backup_value: Value = serde_json::from_str(&backup).expect("backup JSON");
3676 let input_dir = backup_value
3677 .pointer("/result/data/backup_dir")
3678 .and_then(Value::as_str)
3679 .map(ToOwned::to_owned);
3680 store
3681 .adapter
3682 .transact(
3683 &store.config,
3684 &store.schema,
3685 &Transaction {
3686 steps: vec![Mutation::Update {
3687 entity: "record".to_owned(),
3688 filter: Predicate::Equal("id".to_owned(), Value::from(1)),
3689 values: std::collections::BTreeMap::from([(
3690 "title".to_owned(),
3691 WriteValue::Literal(Value::String("changed".to_owned())),
3692 )]),
3693 expected_rows: Some(1),
3694 }],
3695 idempotency_key: None,
3696 },
3697 )
3698 .expect("logical mutation");
3699 let restore = input_dir.as_ref().map(|input_dir| execute_json_rpc(&serde_json::json!({"jsonrpc":"2.0","id":"restore","method":"pharos.restore_app","params":{"app_id":"ops_app","app_dir":"apps/ops_app","input_dir":input_dir}}).to_string()));
3700 std::env::set_current_dir(original_dir).expect("restore cwd");
3701 assert_eq!(
3702 backup_value
3703 .pointer("/result/data/status")
3704 .and_then(Value::as_str),
3705 Some("backed_up")
3706 );
3707 let restore_value: Value =
3708 serde_json::from_str(&restore.expect("restore result")).expect("restore JSON");
3709 assert_eq!(
3710 restore_value
3711 .pointer("/result/data/status")
3712 .and_then(Value::as_str),
3713 Some("restored")
3714 );
3715 let rows = store
3716 .adapter
3717 .query(
3718 &store.config,
3719 &store.schema,
3720 &Query {
3721 entity: "record".to_owned(),
3722 fields: vec!["title".to_owned()],
3723 filter: Some(Predicate::Equal("id".to_owned(), Value::from(1))),
3724 order: Vec::new(),
3725 limit: 1,
3726 offset: 0,
3727 },
3728 )
3729 .expect("restored query");
3730 assert_eq!(rows[0]["title"], "original");
3731 assert_eq!(
3732 fs::read_to_string(root.join("tmp/state.json")).expect("unrelated file"),
3733 r#"{"items":[]}"#
3734 );
3735 }
3736
3737 #[test]
3738 fn json_rpc_executes_jobs_and_integration_native_surfaces() {
3740 let _bridge_test_guard = bridge_test_lock();
3741 let root = temp_root("rpc-jobs-integrations");
3742 fs::create_dir_all(root.join("apps/ops_app/data/contracts")).expect("contracts");
3743 fs::create_dir_all(root.join("dist/apps/ops_app/data/contracts")).expect("integrations");
3744 fs::create_dir_all(root.join("tmp")).expect("tmp");
3745 fs::write(
3746 root.join("apps/ops_app/pharos.app.yml"),
3747 "app_id: ops_app\ndatabase:\n migration_contract: data/contracts/database.json\n",
3748 )
3749 .expect("manifest");
3750 fs::write(
3751 root.join("apps/ops_app/app.conf"),
3752 format!(
3753 "db_backend=sqlite\ndb_path={}\n",
3754 root.join("tmp/jobs.sqlite3").display()
3755 ),
3756 )
3757 .expect("app config");
3758 let job_entity = serde_json::json!({
3759 "name": "job_execution", "identity": {"auto_integer": "id"},
3760 "fields": [
3761 {"name":"id","kind":"integer","nullable":false,"constraints":[]},
3762 {"name":"job_id","kind":"text","nullable":false,"max_length":128,"constraints":[]},
3763 {"name":"handler_id","kind":"text","nullable":false,"max_length":128,"constraints":[]},
3764 {"name":"queue_name","kind":"text","nullable":false,"max_length":128,"constraints":[]},
3765 {"name":"execution_status","kind":"text","nullable":false,"max_length":128,"constraints":[]},
3766 {"name":"attempt_count","kind":"integer","nullable":false,"constraints":[]},
3767 {"name":"completed_at","kind":"timestamp","nullable":true,"constraints":[]},
3768 {"name":"available_at","kind":"timestamp","nullable":false,"default":{"literal":"1970-01-01T00:00:00Z"},"constraints":[]},
3769 {"name":"max_attempts","kind":"integer","nullable":false,"default":{"literal":3},"constraints":[]},
3770 {"name":"workflow_execution_id","kind":"integer","nullable":true,"constraints":[]}
3771 ], "unique": [], "indexes": [], "conditional_indexes": []
3772 });
3773 let reference_contract = pharos_runtime_compiler::database::load_source(
3774 &Path::new(env!("CARGO_MANIFEST_DIR")).join("apps/ucal/data/contracts/database.yml"),
3775 "ucal",
3776 )
3777 .expect("shared integration operation declaration");
3778 let mut delivery = reference_contract
3779 .schema
3780 .entities
3781 .iter()
3782 .find(|entity| entity.name == "integration_delivery")
3783 .unwrap()
3784 .clone();
3785 for field in &mut delivery.fields {
3787 field.references = None;
3788 field.on_delete = None;
3789 }
3790 let delivery_entity = serde_json::to_value(&delivery).unwrap();
3791 let changes: Vec<pharos_runtime_database::operations::SchemaChange> =
3792 serde_json::from_value(serde_json::json!([{"create_entity":job_entity.clone()}, {"create_entity":delivery_entity.clone()}]))
3793 .expect("logical changes");
3794 let contract = pharos_runtime_database::contract::AppDataContract {
3795 schema: serde_json::from_value(serde_json::json!({
3796 "app_id":"ops_app", "entities":[job_entity, delivery_entity]
3797 }))
3798 .expect("logical schema"),
3799 migrations: vec![pharos_runtime_database::operations::Migration {
3800 app_id: "ops_app".to_owned(),
3801 id: "jobs".to_owned(),
3802 checksum: pharos_runtime_database::contract::migration_checksum(
3803 "ops_app", "jobs", &changes,
3804 )
3805 .expect("logical checksum"),
3806 changes,
3807 }],
3808 legacy_adoption: None,
3809 };
3810 fs::write(
3811 root.join("apps/ops_app/data/contracts/database.json"),
3812 serde_json::to_vec(&contract).expect("encode logical contract"),
3813 )
3814 .expect("write logical contract");
3815 for relative in ["pharos.app.yml", "app.conf", "data/contracts/database.json"] {
3816 fs::copy(
3817 root.join("apps/ops_app").join(relative),
3818 root.join("dist/apps/ops_app").join(relative),
3819 )
3820 .expect("staged logical app inputs");
3821 }
3822 let database_config = pharos_runtime_database::DbBackendConfig {
3823 backend: "sqlite".to_owned(),
3824 sqlite_path: Some(root.join("tmp/jobs.sqlite3")),
3825 ..Default::default()
3826 };
3827 let adapter = super::super::database::operations::resolve(&database_config)
3828 .expect("configured jobs adapter");
3829 adapter
3830 .apply_contract(&database_config, &contract)
3831 .expect("apply logical jobs schema");
3832 adapter
3833 .transact(
3834 &database_config,
3835 &contract.schema,
3836 &pharos_runtime_database::operations::Transaction {
3837 steps: vec![pharos_runtime_database::operations::Mutation::Insert {
3838 entity: "job_execution".to_owned(),
3839 values: BTreeMap::from([
3840 (
3841 "job_id".to_owned(),
3842 pharos_runtime_database::operations::WriteValue::Literal(
3843 serde_json::json!("send-reminder"),
3844 ),
3845 ),
3846 (
3847 "handler_id".to_owned(),
3848 pharos_runtime_database::operations::WriteValue::Literal(
3849 serde_json::json!("local_noop"),
3850 ),
3851 ),
3852 (
3853 "queue_name".to_owned(),
3854 pharos_runtime_database::operations::WriteValue::Literal(
3855 serde_json::json!("default"),
3856 ),
3857 ),
3858 (
3859 "execution_status".to_owned(),
3860 pharos_runtime_database::operations::WriteValue::Literal(
3861 serde_json::json!("queued"),
3862 ),
3863 ),
3864 (
3865 "attempt_count".to_owned(),
3866 pharos_runtime_database::operations::WriteValue::Literal(
3867 serde_json::json!(0),
3868 ),
3869 ),
3870 ]),
3871 }],
3872 idempotency_key: None,
3873 },
3874 )
3875 .expect("seed logical job");
3876 fs::write(root.join("tmp/state.json"), r#"{"integration_deliveries":[],"integration_delivery_attempts":[],"webhook_receipts":[]}"#).expect("state");
3877 fs::write(
3878 root.join("dist/apps/ops_app/data/contracts/integrations.json"),
3879 r#"{"adapters":[{"adapter_id":"local_webhook","adapter_kind":"webhook","target_url":"http://localhost:1/receive","content_type":"application/json","max_attempts":1,"timeout_seconds":1,"allow_plaintext_local":true}]}"#,
3880 )
3881 .expect("integrations");
3882 let original_dir = std::env::current_dir().expect("cwd");
3883 std::env::set_current_dir(&root).expect("chdir");
3884 let jobs = execute_json_rpc(
3885 r#"{"jsonrpc":"2.0","id":"jobs","method":"pharos.run_jobs","params":{"app_id":"ops_app","app_dir":"apps/ops_app","db_path":"tmp/jobs.sqlite3","backend":"sqlite","case":"first_queued"}}"#,
3886 );
3887 let integration = execute_json_rpc(
3888 r#"{"jsonrpc":"2.0","id":"integration","method":"pharos.deliver_integration","params":{"app_id":"ops_app","build_dir":"dist","state_path":"tmp/state.json","adapter_id":"local_webhook","event_kind":"ops_ready","resource_id":"task:1","payload_json":"{\"ok\":true}"}}"#,
3889 );
3890 std::env::set_current_dir(original_dir).expect("restore dir");
3891 let jobs_value: Value = serde_json::from_str(&jobs).expect("jobs json");
3892 assert_eq!(
3893 jobs_value
3894 .pointer("/result/data/status")
3895 .and_then(Value::as_str),
3896 Some("ok")
3897 );
3898 assert!(jobs_value.to_string().contains("succeeded"));
3899 let integration_value: Value =
3900 serde_json::from_str(&integration).expect("integration json");
3901 assert_eq!(
3902 integration_value
3903 .pointer("/result/data/report_id")
3904 .and_then(Value::as_str),
3905 Some("pharos-integrations-inspect-v1"),
3906 "integration response: {integration_value}"
3907 );
3908 assert_eq!(
3909 integration_value
3910 .pointer("/result/data/operation/queued_delivery_count")
3911 .and_then(Value::as_i64),
3912 Some(1)
3913 );
3914 let state = fs::read_to_string(root.join("tmp/state.json")).expect("state");
3915 assert_eq!(
3916 serde_json::from_str::<Value>(&state).unwrap(),
3917 serde_json::json!({"integration_deliveries":[],"integration_delivery_attempts":[],"webhook_receipts":[]})
3918 );
3919 let deliveries = pharos_runtime_database::integration::integration_rows(
3920 adapter.as_ref(),
3921 &database_config,
3922 &contract.schema,
3923 "integration_delivery",
3924 10,
3925 )
3926 .unwrap();
3927 assert_eq!(deliveries.len(), 1);
3928 assert_eq!(deliveries[0]["adapter_id"], "local_webhook");
3929 assert_eq!(deliveries[0]["event_kind"], "ops_ready");
3930 }
3931
3932 #[test]
3933 fn json_rpc_exposes_target_and_readiness_surfaces() {
3935 let _bridge_test_guard = bridge_test_lock();
3936 let root = temp_root("rpc-target-readiness");
3937 fs::create_dir_all(root.join("apps")).expect("apps");
3938 fs::create_dir_all(root.join("scripts")).expect("scripts");
3939 fs::create_dir_all(root.join("data/framework")).expect("framework");
3940 fs::write(
3941 root.join("data/framework/cross-platform-target-registry-v1.json"),
3942 r#"{"schema":"pharos.cross_platform_target_registry.v1","status":"declared","target_count":1,"targets":[{"target_id":"macos-x86_64","os":"macos","cpu":"x86_64","abi":"darwin","native_binary_status":"supported"}]}"#,
3943 )
3944 .expect("registry");
3945 let original_dir = std::env::current_dir().expect("cwd");
3946 std::env::set_current_dir(&root).expect("chdir");
3947 let targets = execute_json_rpc(
3948 r#"{"jsonrpc":"2.0","id":"targets","method":"pharos.list_targets","params":{"format":"json"}}"#,
3949 );
3950 let readiness = execute_json_rpc(
3951 r#"{"jsonrpc":"2.0","id":"ready","method":"pharos.production_readiness","params":{"app_id":"architect"}}"#,
3952 );
3953 std::env::set_current_dir(original_dir).expect("restore dir");
3954 let targets_value: Value = serde_json::from_str(&targets).expect("targets json");
3955 assert_eq!(
3956 targets_value
3957 .pointer("/result/data/schema")
3958 .and_then(Value::as_str),
3959 Some("pharos.targets.v1")
3960 );
3961 let readiness_value: Value = serde_json::from_str(&readiness).expect("readiness json");
3962 assert_eq!(
3963 readiness_value
3964 .pointer("/result/data/schema")
3965 .and_then(Value::as_str),
3966 Some("pharos.agent.production_readiness.v1")
3967 );
3968 assert!(
3969 readiness_value
3970 .pointer("/result/data/checks")
3971 .and_then(Value::as_array)
3972 .expect("checks")
3973 .iter()
3974 .any(|check| check.get("capability_id").and_then(Value::as_str)
3975 == Some("builtin_offline_speech_to_text")
3976 && check.get("status").and_then(Value::as_str) == Some("blocked"))
3977 );
3978 assert_eq!(
3979 readiness_value
3980 .pointer("/result/data/local_detection/media_artifacts/rust_native_only")
3981 .and_then(Value::as_bool),
3982 Some(true)
3983 );
3984 assert_eq!(
3985 readiness_value
3986 .pointer(
3987 "/result/data/local_detection/media_artifacts/capabilities/offline_rust_stt"
3988 )
3989 .and_then(Value::as_bool),
3990 Some(false)
3991 );
3992 }
3993
3994 #[test]
3995 fn target_registry_projection_matches_registry_targets() {
3997 let _bridge_test_guard = bridge_test_lock();
3998 let repo = repo_root();
3999 let registry = read_target_registry(&repo).expect("registry");
4000 let registry_targets = registry_targets(®istry).expect("registry targets");
4001 let projection: Value =
4002 serde_json::from_str(&target_registry_projection(&repo).expect("projection"))
4003 .expect("projection json");
4004 assert_eq!(
4005 projection.get("schema").and_then(Value::as_str),
4006 Some("pharos.targets.v1")
4007 );
4008 assert_eq!(
4009 projection.get("target_count").and_then(Value::as_u64),
4010 Some(registry_targets.len() as u64)
4011 );
4012 assert_eq!(
4013 projection
4014 .get("targets")
4015 .and_then(Value::as_array)
4016 .expect("projected targets"),
4017 ®istry_targets
4018 );
4019 }
4020
4021 #[test]
4022 fn host_detection_uses_registry_targets_and_unknown_advice() {
4024 let _bridge_test_guard = bridge_test_lock();
4025 let repo = repo_root();
4026 let detected: Value =
4027 serde_json::from_str(&host_detection_report(&repo, "Darwin", "arm64").expect("host"))
4028 .expect("host json");
4029 assert_eq!(
4030 detected.get("status").and_then(Value::as_str),
4031 Some("detected")
4032 );
4033 assert_eq!(
4034 detected.get("host_build_target").and_then(Value::as_str),
4035 Some("macos-arm64")
4036 );
4037 assert_eq!(
4038 detected
4039 .get("release_target_candidate")
4040 .and_then(Value::as_str),
4041 Some("macos-arm64")
4042 );
4043
4044 let unknown: Value =
4045 serde_json::from_str(&host_detection_report(&repo, "plan9", "mips").expect("unknown"))
4046 .expect("unknown json");
4047 assert_eq!(
4048 unknown.get("status").and_then(Value::as_str),
4049 Some("unknown")
4050 );
4051 assert_eq!(
4052 unknown.get("host_build_target").and_then(Value::as_str),
4053 Some("unknown")
4054 );
4055 assert!(
4056 unknown
4057 .get("advice")
4058 .and_then(Value::as_str)
4059 .expect("advice")
4060 .contains("Pass --target explicitly")
4061 );
4062 }
4063
4064 #[test]
4065 fn container_runtime_contracts_keep_package_and_state_boundaries() {
4067 let _bridge_test_guard = bridge_test_lock();
4068 let repo = repo_root();
4069 let control =
4070 fs::read_to_string(repo.join("cross/packaging/debian/control.in")).expect("control");
4071 assert!(control.contains("Depends: adduser, sqlite3\n"));
4072 assert!(
4073 !control
4074 .lines()
4075 .find(|line| line.starts_with("Depends:"))
4076 .expect("depends")
4077 .contains("systemd")
4078 );
4079 assert!(control.contains("Suggests: redis-server, systemd\n"));
4080
4081 let postinst =
4082 fs::read_to_string(repo.join("cross/packaging/debian/postinst")).expect("postinst");
4083 assert!(postinst.contains("[ \"${PHAROS_CONTAINER:-0}\" != \"1\" ]"));
4084 assert!(postinst.contains("systemctl enable pharos.service pharos-ha.service"));
4085 assert!(postinst.contains("/usr/bin/pharos migrate apply app"));
4086
4087 let prerm = fs::read_to_string(repo.join("cross/packaging/debian/prerm")).expect("prerm");
4088 assert!(prerm.contains("[ \"${PHAROS_CONTAINER:-0}\" = \"1\" ]"));
4089 assert!(prerm.contains("systemctl stop pharos-ha.service pharos.service"));
4090
4091 let dockerfile =
4092 fs::read_to_string(repo.join("containers/Dockerfile.runtime")).expect("dockerfile");
4093 assert!(dockerfile.contains("FROM ubuntu:24.04@sha256:"));
4094 assert!(dockerfile.contains("ENV PHAROS_CONTAINER=1"));
4095 assert!(dockerfile.contains("apt-get install -y --no-install-recommends"));
4096 assert!(dockerfile.contains("USER pharos:pharos"));
4097 assert!(dockerfile.contains("HEALTHCHECK"));
4098 assert!(dockerfile.contains("\"/srv/pharos/apps\""));
4099 assert!(dockerfile.contains("\"/var/lib/pharos/media\""));
4100 assert!(!dockerfile.contains("systemctl"));
4101 assert!(!dockerfile.contains("cargo build"));
4102 assert!(dockerfile.contains("test ! -d /root/.cargo"));
4103
4104 let compose = fs::read_to_string(repo.join("containers/compose/pharos-runtime.yml"))
4105 .expect("compose");
4106 for volume in [
4107 "pharos-apps:/srv/pharos/apps:ro",
4108 "pharos-data:/var/lib/pharos",
4109 "pharos-state:/var/state/pharos",
4110 "pharos-media:/var/lib/pharos/media",
4111 "pharos-architect-vault:/var/lib/pharos/architect/vault",
4112 ] {
4113 assert!(compose.contains(volume), "missing compose volume {volume}");
4114 }
4115 assert!(compose.contains("read_only: true"));
4116 assert!(compose.contains("PHAROS_ARCHITECT_ROOT_KEY"));
4117
4118 let kube = fs::read_to_string(repo.join("containers/kubernetes/pharos-runtime.yml"))
4119 .expect("kubernetes");
4120 assert!(kube.contains("runAsNonRoot: true"));
4121 assert!(kube.contains("path: /ready"));
4122 assert!(kube.contains("path: /live"));
4123 assert!(kube.contains("mountPath: /srv/pharos/apps, readOnly: true"));
4124 assert!(kube.contains("mountPath: /run/secrets/pharos_architect_root_key, readOnly: true"));
4125 assert!(kube.contains("claimName: pharos-media"));
4126 assert!(kube.contains("claimName: pharos-architect-vault"));
4127 }
4128
4129 #[test]
4130 fn legacy_app_lifecycle_verbs_remain_active() {
4132 let _bridge_test_guard = bridge_test_lock();
4133 let verbs = active_cli_verbs();
4134 for verb in ["build", "verify", "doctor", "migrate", "scaffold", "seed"] {
4135 let entry = verbs
4136 .iter()
4137 .find(|entry| entry.get("verb").and_then(Value::as_str) == Some(verb))
4138 .expect("verb entry");
4139 assert_eq!(entry.get("active").and_then(Value::as_bool), Some(true));
4140 }
4141 for verb in ["targets", "host"] {
4142 let entry = verbs
4143 .iter()
4144 .find(|entry| entry.get("verb").and_then(Value::as_str) == Some(verb))
4145 .expect("target verb entry");
4146 assert_eq!(
4147 entry.get("owner").and_then(Value::as_str),
4148 Some("target_registry")
4149 );
4150 assert_eq!(entry.get("active").and_then(Value::as_bool), Some(true));
4151 }
4152 assert_eq!(
4153 super::super::runtime_report_id_for_verb("build"),
4154 "pharos-local-app-build-v1"
4155 );
4156 assert_eq!(
4157 super::super::runtime_report_id_for_verb("verify"),
4158 "pharos-local-app-build-v1"
4159 );
4160 assert_eq!(
4161 super::super::runtime_report_id_for_verb("doctor"),
4162 "pharos-doctor-v1"
4163 );
4164 }
4165
4166 #[test]
4167 fn baseline_inventory_rejects_external_output_root() {
4169 let _bridge_test_guard = bridge_test_lock();
4170 let outside =
4171 std::env::temp_dir().join(format!("pharos-agent-outside-{}", std::process::id()));
4172 assert_eq!(write_baseline_inventory(&repo_root(), &outside), Err(64));
4173 assert_eq!(
4174 write_baseline_inventory(&repo_root(), Path::new("../outside")),
4175 Err(64)
4176 );
4177 }
4178
4179 #[test]
4180 fn json_rpc_rejects_malformed_and_oversized_requests() {
4182 let _bridge_test_guard = bridge_test_lock();
4183 assert_eq!(
4184 parse_json_rpc_request("{not-json"),
4185 Err(AgentErrorKind::InvalidJson)
4186 );
4187 let oversized = format!(
4188 r#"{{"jsonrpc":"2.0","id":"req","method":"pharos.query_index","params":{{"body":"{}"}}}}"#,
4189 "x".repeat(RPC_REQUEST_LIMIT_BYTES)
4190 );
4191 assert_eq!(
4192 parse_json_rpc_request(&oversized),
4193 Err(AgentErrorKind::CapacityExceeded)
4194 );
4195 }
4196
4197 #[test]
4198 fn json_rpc_rejects_unknown_method_shape_and_invalid_params() {
4200 let _bridge_test_guard = bridge_test_lock();
4201 let unknown = r#"{"jsonrpc":"2.0","id":"req-1","method":"query_index","params":{}}"#;
4202 assert_eq!(
4203 parse_json_rpc_request(unknown),
4204 Err(AgentErrorKind::MethodNotFound)
4205 );
4206 let bad_params =
4207 r#"{"jsonrpc":"2.0","id":"req-1","method":"pharos.query_index","params":[]}"#;
4208 assert_eq!(
4209 parse_json_rpc_request(bad_params),
4210 Err(AgentErrorKind::InvalidEnvelope)
4211 );
4212 let valid = parse_json_rpc_request(
4213 r#"{"jsonrpc":"2.0","id":"req-1","method":"pharos.query_index","params":{"query":"routes"}}"#,
4214 )
4215 .expect("valid request");
4216 assert_eq!(valid.method, "pharos.query_index");
4217 }
4218
4219 #[test]
4220 fn app_identifier_normalization_rejects_injection_shapes() {
4222 let _bridge_test_guard = bridge_test_lock();
4223 assert_eq!(normalize_app_id("todo_list").expect("app id"), "todo_list");
4224 assert_eq!(
4225 normalize_app_id("../todo"),
4226 Err(AgentErrorKind::InvalidIdentifier)
4227 );
4228 assert_eq!(
4229 normalize_app_id("todo list"),
4230 Err(AgentErrorKind::InvalidIdentifier)
4231 );
4232 assert_eq!(
4233 normalize_app_id(&"a".repeat(65)),
4234 Err(AgentErrorKind::InvalidIdentifier)
4235 );
4236 }
4237
4238 #[test]
4239 fn path_classification_rejects_traversal_and_classifies_outputs() {
4241 let _bridge_test_guard = bridge_test_lock();
4242 assert_eq!(
4243 classify_repo_path("apps/todo_list/pharos.app.yml").expect("source"),
4244 PathClassification::Source
4245 );
4246 assert_eq!(
4247 classify_repo_path("target/agent/baseline-inventory.json").expect("artifact"),
4248 PathClassification::Artifact
4249 );
4250 assert_eq!(
4251 classify_repo_path("tmp/pharos-agent/test").expect("temp"),
4252 PathClassification::Temp
4253 );
4254 assert_eq!(
4255 classify_repo_path("../apps/todo_list"),
4256 Err(AgentErrorKind::InvalidPath)
4257 );
4258 }
4259
4260 #[test]
4261 fn repo_path_resolution_rejects_symlink_escape() {
4263 let _bridge_test_guard = bridge_test_lock();
4264 let root = temp_root("symlink");
4265 fs::create_dir_all(root.join("apps")).expect("apps");
4266 let outside = temp_root("outside");
4267 let link = root.join("apps/outside-link");
4268 #[cfg(unix)]
4269 std::os::unix::fs::symlink(&outside, &link).expect("symlink");
4270 #[cfg(windows)]
4271 std::os::windows::fs::symlink_dir(&outside, &link).expect("symlink");
4272 assert_eq!(
4273 resolve_existing_repo_path(&root, "apps/outside-link"),
4274 Err(AgentErrorKind::InvalidPath)
4275 );
4276 }
4277
4278 #[test]
4279 fn stale_plan_digest_is_rejected() {
4281 let _bridge_test_guard = bridge_test_lock();
4282 let current = "a".repeat(64);
4283 assert_eq!(plan_digest_current(¤t, ¤t), Ok(()));
4284 assert_eq!(
4285 plan_digest_current(&"b".repeat(64), ¤t),
4286 Err(AgentErrorKind::StalePlan)
4287 );
4288 assert_eq!(
4289 plan_digest_current("not-a-digest", ¤t),
4290 Err(AgentErrorKind::InvalidIdentifier)
4291 );
4292 }
4293
4294 #[test]
4295 fn secret_like_text_is_redacted() {
4297 let _bridge_test_guard = bridge_test_lock();
4298 let (text, changed) = redact_secret_like_text("api_key=abc123 visible token=xyz");
4299 assert!(changed);
4300 assert_eq!(text, "[REDACTED] visible [REDACTED]");
4301 }
4302
4303 #[test]
4304 fn operation_lock_rejects_concurrent_writers() {
4306 let _bridge_test_guard = bridge_test_lock();
4307 let root = temp_root("lock");
4308 let first = acquire_operation_lock(&root).expect("first lock");
4309 assert_eq!(
4310 acquire_operation_lock(&root).err(),
4311 Some(AgentErrorKind::LockBusy)
4312 );
4313 drop(first);
4314 assert!(acquire_operation_lock(&root).is_ok());
4315 }
4316
4317 #[test]
4318 fn encrypted_credential_backend_round_trips_without_plaintext_reports() {
4320 let _bridge_test_guard = bridge_test_lock();
4321 let root = temp_root("vault");
4322 let backend = EncryptedFileCredentialBackend::new(
4323 root.join("vault/agent.vault.enc"),
4324 b"01234567890123456789012345678901".to_vec(),
4325 )
4326 .expect("backend");
4327 let first = backend
4328 .put_secret("llm.mock.api_key", b"sk-test-secret")
4329 .expect("put secret");
4330 assert_eq!(first.ref_name, "llm.mock.api_key");
4331 assert!(first.present);
4332 assert_eq!(
4333 backend.get_secret("llm.mock.api_key").expect("read secret"),
4334 b"sk-test-secret"
4335 );
4336 let rotated = backend
4337 .put_secret("llm.mock.api_key", b"sk-rotated-secret")
4338 .expect("rotate secret");
4339 assert_eq!(rotated.revision, first.revision + 1);
4340 let listed = backend.list_secret_refs().expect("list refs");
4341 assert_eq!(listed.len(), 1);
4342 assert_eq!(listed[0].ref_name, "llm.mock.api_key");
4343 let vault_text = fs::read_to_string(root.join("vault/agent.vault.enc")).expect("vault");
4344 assert!(!vault_text.contains("sk-test-secret"));
4345 assert!(!vault_text.contains("sk-rotated-secret"));
4346 assert!(backend.unset_secret("llm.mock.api_key").expect("unset"));
4347 assert!(backend.list_secret_refs().expect("list empty").is_empty());
4348 }
4349
4350 #[test]
4351 fn encrypted_credential_backend_fails_closed_for_missing_key_and_tamper() {
4353 let _bridge_test_guard = bridge_test_lock();
4354 let root = temp_root("vault-tamper");
4355 let vault_path = root.join("vault/agent.vault.enc");
4356 assert_eq!(
4357 EncryptedFileCredentialBackend::new(vault_path.clone(), b"short".to_vec()).err(),
4358 Some(AgentErrorKind::CredentialUnavailable)
4359 );
4360 let backend = EncryptedFileCredentialBackend::new(
4361 vault_path.clone(),
4362 b"01234567890123456789012345678901".to_vec(),
4363 )
4364 .expect("backend");
4365 backend
4366 .put_secret("llm.mock.api_key", b"sk-test-secret")
4367 .expect("put secret");
4368 fs::write(&vault_path, "{\"schema\":\"pharos.agent.encrypted_credential_vault.v1\",\"salt_hex\":\"00\",\"nonce_hex\":\"00\",\"ciphertext_hex\":\"00\"}\n")
4369 .expect("tamper");
4370 assert_eq!(
4371 backend.get_secret("llm.mock.api_key").err(),
4372 Some(AgentErrorKind::CredentialUnavailable)
4373 );
4374 }
4375
4376 #[cfg(unix)]
4377 #[test]
4378 fn encrypted_credential_backend_sets_restrictive_permissions() {
4380 let _bridge_test_guard = bridge_test_lock();
4381 use std::os::unix::fs::PermissionsExt;
4382
4383 let root = temp_root("vault-perms");
4384 let vault_path = root.join("vault/agent.vault.enc");
4385 let backend = EncryptedFileCredentialBackend::new(
4386 vault_path.clone(),
4387 b"01234567890123456789012345678901".to_vec(),
4388 )
4389 .expect("backend");
4390 backend
4391 .put_secret("llm.mock.api_key", b"sk-test-secret")
4392 .expect("put secret");
4393 let dir_mode = fs::metadata(root.join("vault"))
4394 .expect("dir metadata")
4395 .permissions()
4396 .mode()
4397 & 0o777;
4398 let file_mode = fs::metadata(vault_path)
4399 .expect("file metadata")
4400 .permissions()
4401 .mode()
4402 & 0o777;
4403 assert_eq!(dir_mode, 0o700);
4404 assert_eq!(file_mode, 0o600);
4405 }
4406
4407 #[test]
4408 fn target_profile_parsing_enforces_policy() {
4410 let _bridge_test_guard = bridge_test_lock();
4411 let profile = parse_target_profile(
4412 r#"{"schema":"pharos.agent.target_profile.v1","target_id":"qa-1","environment":"qa","transport":"oci_registry","artifact_kind":"oci_service","allowed_app_ids":["todo_list"],"secret_refs":["deploy.targets.qa-1.registry_token"]}"#,
4413 )
4414 .expect("profile");
4415 assert_eq!(profile.target_id, "qa-1");
4416
4417 assert_eq!(
4418 parse_target_profile(
4419 r#"{"schema":"pharos.agent.target_profile.v1","target_id":"qa-1","environment":"qa","transport":"oci_registry","artifact_kind":"native_package","allowed_app_ids":["todo_list"],"secret_refs":["deploy.targets.qa-1.registry_token"]}"#
4420 )
4421 .err(),
4422 Some(AgentErrorKind::PolicyViolation)
4423 );
4424 assert_eq!(
4425 parse_target_profile(
4426 r#"{"schema":"pharos.agent.target_profile.v1","target_id":"prod-1","environment":"production","transport":"ssh_debian","artifact_kind":"native_package","allowed_app_ids":["todo_list"],"secret_refs":[]}"#
4427 )
4428 .err(),
4429 Some(AgentErrorKind::PolicyViolation)
4430 );
4431 assert_eq!(
4432 parse_target_profile(
4433 r#"{"schema":"pharos.agent.target_profile.v1","target_id":"qa-1","environment":"qa","transport":"kubernetes","artifact_kind":"oci_service","allowed_app_ids":["../todo"],"secret_refs":["deploy.targets.qa-1.kubeconfig"]}"#
4434 )
4435 .err(),
4436 Some(AgentErrorKind::InvalidIdentifier)
4437 );
4438 assert_eq!(
4439 parse_target_profile(
4440 r#"{"schema":"pharos.agent.target_profile.v1","target_id":"qa-1","environment":"qa","transport":"kubernetes","artifact_kind":"oci_service","allowed_app_ids":["todo_list"],"secret_refs":["token=secret"]}"#
4441 )
4442 .err(),
4443 Some(AgentErrorKind::InvalidIdentifier)
4444 );
4445 }
4446}