Skip to main content

pharos_native/runtime/
agent_bridge.rs

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/// Versioned target profile record supplied by Architect configuration.
34#[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
129/// Executes one JSON-RPC request through the crate-owned dispatch table.
130pub 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
137/// Executes the built-in app-model synthesis JSON-RPC method.
138fn execute_synthesize_app_model(request: JsonRpcRequest) -> String {
139    pharos_runtime_architect::json_rpc::execute_synthesize_app_model(request)
140}
141
142/// Executes the shared scaffold helper through the bounded JSON-RPC method.
143fn 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(&params.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(&params.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
199/// Executes a read-only app model inspection for one checked-in app root.
200fn 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(&params.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
217/// Reads the current declarative model surfaces for one source app root.
218pub 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
223/// Executes deterministic source-change planning without mutating app source.
224fn execute_plan_app_change(request: JsonRpcRequest) -> String {
225    pharos_runtime_architect::json_rpc::execute_plan_app_change(request)
226}
227
228/// Executes a bounded inspection for one saved change plan.
229fn execute_inspect_change(request: JsonRpcRequest) -> String {
230    pharos_runtime_architect::json_rpc::execute_inspect_change(request)
231}
232
233/// Applies one previously planned app change atomically with preimage rollback.
234fn 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
248/// Executes one generic runtime lifecycle command through the agent bridge.
249fn 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        &params.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
266/// Executes the standalone seed compatibility tool without shelling out.
267fn 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        &params.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
356/// Executes build, verify, and doctor as the local validation bundle.
357fn 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        &params.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            &params.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            &params.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
406/// Executes one native backup operation through the agent bridge.
407fn 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        &params.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
441/// Executes one native restore operation through the agent bridge.
442fn 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        &params.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(&params.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
474/// Executes bounded job inspection or local job execution through the agent bridge.
475fn 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(&params, operation) {
481        Ok(value) => json_rpc_success(request.id, value),
482        Err(error) => json_rpc_failure(Some(request.id), error),
483    }
484}
485
486/// Executes bounded integration inspection or delivery through the agent bridge.
487fn 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(&params, operation) {
493        Ok(value) => json_rpc_success(request.id, value),
494        Err(error) => json_rpc_failure(Some(request.id), error),
495    }
496}
497
498/// Inspects or processes queued local job rows through the configured adapter.
499fn 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(&params.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
559/// Inspects or delivers one configured integration event from app-owned contracts.
560pub 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(&params.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(&params.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        // Import is intentionally left to the app runtime config path and fails closed if unavailable.
717    }
718    Ok(value)
719}
720
721/// Executes the declared deployment gate without claiming remote transport completion.
722fn 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(&params) {
728        Ok(value) => json_rpc_success(request.id, value),
729        Err(error) => json_rpc_failure(Some(request.id), error),
730    }
731}
732
733/// Lists checked-in cross-platform targets through JSON-RPC.
734fn 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
755/// Detects the local host target through JSON-RPC.
756fn 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
776/// Reports production readiness for hard integration capabilities.
777fn 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
788/// Runs build, verify, or doctor through the existing generic app lifecycle dispatcher.
789pub 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    // Lifecycle inputs are relative to the active validation workspace. A
826    // generated app workspace has apps/ but no repository scripts/ directory.
827    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
838/// Applies seed data through the existing runtime migration preparation path.
839pub 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    // An app without a database declaration has no database seed operation.
882    // Never infer an alternate migration path from files in its source tree.
883    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
891/// Normalizes lifecycle paths without permitting traversal.
892fn 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
910/// Builds the same app runtime context used by native backup and restore entrypoints.
911fn 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
965/// Returns a deploy gate report backed by built artifacts and target registry data.
966fn deploy_target_gate(params: &DeployTargetParams) -> Result<Value, AgentErrorKind> {
967    let app_id = normalize_app_id(&params.app_id)?;
968    let target_id = normalize_app_id(&params.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(&registry_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
1018/// Reports the maturity of hard production integrations without faking missing engines.
1019pub 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
1223/// Resolves the current Pharos repository root for local agent commands.
1224fn 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
1240/// Serializes a JSON-RPC failure envelope with stable advice.
1241fn json_rpc_failure(id: Option<String>, kind: AgentErrorKind) -> String {
1242    pharos_runtime_architect::json_rpc::json_rpc_failure(id, kind)
1243}
1244
1245/// Parses a bounded JSON-RPC request envelope.
1246pub fn parse_json_rpc_request(input: &str) -> Result<JsonRpcRequest, AgentErrorKind> {
1247    pharos_runtime_architect::json_rpc::parse_json_rpc_request(input)
1248}
1249
1250/// Validates the JSON-RPC envelope fields used for dispatch.
1251pub fn validate_json_rpc_request(request: &JsonRpcRequest) -> Result<(), AgentErrorKind> {
1252    pharos_runtime_architect::json_rpc::validate_json_rpc_request(request)
1253}
1254
1255/// Parses and validates one versioned target profile document.
1256pub 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
1263/// Validates target-profile policy without requiring any tracked target addresses.
1264pub 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
1293/// Validates deterministic product knowledge outputs without writing files.
1294pub fn check_product_knowledge(repo_root: &Path) -> Result<String, i32> {
1295    pharos_runtime_architect::product_knowledge::check_product_knowledge(repo_root)
1296}
1297
1298/// Validates written product knowledge artifacts against the current source fingerprint.
1299pub 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
1309/// Writes deterministic product knowledge outputs for the Architect webapp agent.
1310pub 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
1314/// Builds the deterministic agent sync report used by local hooks and checks.
1315pub 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)]
1336/// Validates generated product knowledge documents through the Architect crate.
1337fn 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)]
1347/// Returns product-knowledge source files through the Architect crate.
1348fn knowledge_source_files(repo_root: &Path) -> Vec<String> {
1349    pharos_runtime_architect::product_knowledge::knowledge_source_files(repo_root)
1350}
1351
1352#[cfg(test)]
1353/// Computes a product-knowledge fingerprint with in-memory overrides.
1354fn 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)]
1367/// Writes one pretty JSON output file with a trailing newline.
1368fn 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
1373/// Emits the registry-declared target catalog as a stable JSON projection.
1374pub fn target_registry_projection(repo_root: &Path) -> Result<String, i32> {
1375    pharos_runtime_architect::product_knowledge::target_registry_projection(repo_root)
1376}
1377
1378/// Emits a bounded human-readable target list backed by the registry file.
1379pub 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
1383/// Detects the local host build target using the registry-backed native target map.
1384pub 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
1388/// Writes the Phase 0 agent baseline inventory into the requested repository-local directory.
1389pub 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
1393/// Builds the Phase 0 baseline inventory document from the current source tree.
1394pub fn baseline_inventory_value(repo_root: &Path) -> Result<Value, i32> {
1395    pharos_runtime_architect::product_knowledge::baseline_inventory_value(repo_root)
1396}
1397
1398/// Returns the currently active top-level CLI verbs known at the native entrypoint.
1399pub fn active_cli_verbs() -> Vec<Value> {
1400    pharos_runtime_architect::product_knowledge::active_cli_verbs()
1401}
1402
1403/// Returns the stable report identifiers used by active runtime app lifecycle verbs.
1404pub 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    /// Returns the repository root for tests that inspect checked-in fixtures.
1433    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    /// Creates one isolated filesystem root for agent bridge tests.
1471    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    /// Returns cloned product knowledge outputs after mutating one named output.
1485    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    /// Asserts one mutated product knowledge set returns a diagnostic maturity failure.
1506    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    /// Proves plan, inspect, and apply perform a bounded app-owned text transaction.
1526    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    /// Proves similar-app requests preserve semantic archetype ids without copying source trees.
1597    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    /// Proves generated CRUD and static-page template patches use the same transaction path.
1611    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    /// Proves whole-root replacement attempts are rejected before planning.
1659    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    /// Proves an apply failure restores files already changed in the transaction.
1683    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    /// Proves authoring plans reject writes outside the named app root.
1725    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    /// Proves JSON-RPC reads an existing app model without mutating source.
1749    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    /// Proves JSON-RPC scaffold uses the shared scaffold helper and rejects artifact output roots.
1775    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    /// Proves JSON-RPC can call the built-in app-model synthesis method safely.
1817    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    /// Proves the built-in app-model synthesizer creates a bounded useful baseline model.
1850    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    /// Proves product knowledge generation emits every required local reference without leaking host paths.
2661    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    /// Proves broken product-knowledge inputs produce diagnostic maturity failures.
3203    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    /// Proves source app template/CSS edits change the product-knowledge fingerprint and stale artifacts fail.
3324    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    /// Proves sync enumerates real workflow files and emits typed requested-feature gaps.
3361    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    /// Proves the product knowledge writer stays inside the repository and writes all required outputs.
3544    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    /// Proves baseline inventory reflects the current app roots and target registry.
3569    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(&registry_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    /// Proves JSON-RPC backup and restore use logical records, not physical files.
3603    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        // This unrelated file proves backup no longer treats JSON as relational authority.
3669        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    /// Proves job and integration JSON-RPC methods mutate their local runtime state.
3739    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        // This focused tool test excludes unrelated business/identity records.
3786        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    /// Proves target and readiness JSON-RPC methods report current local prerequisites.
3934    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    /// Proves target projection lists every target from the registry without fixed counts.
3996    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(&registry).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            &registry_targets
4018        );
4019    }
4020
4021    #[test]
4022    /// Proves host detection maps supported hosts and returns actionable unknown reports.
4023    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    /// Proves container runtime manifests and Debian packaging keep mutable state external.
4066    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    /// Proves legacy app lifecycle verbs remain classified as active wrappers.
4131    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    /// Proves the baseline inventory writer refuses output outside the repository.
4168    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    /// Proves JSON-RPC parsing rejects malformed and oversized requests.
4181    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    /// Proves JSON-RPC validation rejects unknown method shapes and invalid params.
4199    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    /// Proves app identifiers use the same bounded ASCII identifier surface.
4221    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    /// Proves repository path classification rejects traversal and separates output classes.
4240    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    /// Proves canonical repository path resolution rejects symlink escape.
4262    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    /// Proves stale or malformed plan digests fail before authoring can proceed.
4280    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(&current, &current), Ok(()));
4284        assert_eq!(
4285            plan_digest_current(&"b".repeat(64), &current),
4286            Err(AgentErrorKind::StalePlan)
4287        );
4288        assert_eq!(
4289            plan_digest_current("not-a-digest", &current),
4290            Err(AgentErrorKind::InvalidIdentifier)
4291        );
4292    }
4293
4294    #[test]
4295    /// Proves secret-looking values are redacted from report text.
4296    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    /// Proves write-capable operations cannot acquire two concurrent repo locks.
4305    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    /// Proves the encrypted fallback backend stores, rotates, lists, and unsets secrets safely.
4319    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    /// Proves the encrypted fallback backend fails closed on missing key authority and tampering.
4352    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    /// Proves the encrypted fallback backend uses restrictive filesystem permissions.
4379    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    /// Proves target profiles parse only non-secret policy-compatible settings.
4409    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}