Skip to main content

pv_harness/
config.rs

1//! Harness configuration (TOML surface, versioned).
2
3use serde::{Deserialize, Serialize};
4
5/// Config format version (evolution surface).
6pub const CONFIG_VERSION: u32 = 1;
7
8/// Upper bound on `worker_channel_capacity`. `tokio::sync::mpsc::channel`
9/// does not preallocate slots (it links chunks lazily under a Semaphore),
10/// but an oversized value still lets the per-shard buffers grow
11/// unboundedly under overload and hides real backpressure signals. 1M
12/// is orders of magnitude above realistic capacity — this bound is
13/// defence-in-depth against a fat-finger, not a hard OOM prevention.
14pub const MAX_WORKER_CHANNEL_CAPACITY: usize = 1_000_000;
15
16/// Upper bound on `mcp_concurrency` (R71). Each admitted MCP call
17/// can buffer up to `MAX_TOOL_RESPONSE_BYTES = 16 MiB` in
18/// `read_limited_tool_response` (routes.rs), so the cap × 16 MiB
19/// bounds worst-case MCP resident memory. 4 096 × 16 MiB = 64 GiB
20/// worst case — a soft ceiling: operators typically want much less,
21/// and a fat-finger config like `mcp_concurrency = 100_000` would
22/// otherwise silently commit the runtime to a memory pressure model
23/// no real host survives. Defence-in-depth like
24/// `MAX_WORKER_CHANNEL_CAPACITY`.
25pub const MAX_MCP_CONCURRENCY: usize = 4_096;
26
27/// Upper bound on `max_request_bytes` (512 MiB). A single request body
28/// should never legitimately need more; lifting this defeats the
29/// sandbox payload guard and lets one request pin half a GB of RAM.
30pub const MAX_REQUEST_BYTES_CAP: usize = 512 * 1024 * 1024;
31
32/// Upper bound on `onnx_dimension`. Most sentence-transformer models
33/// have <= 4096 dims (Nomic Embed v1.5 = 768, MiniLM = 384, e5-mistral
34/// = 4096, Ada-002 = 1536). 16k is a comfortable ceiling.
35pub const MAX_ONNX_DIMENSION: usize = 16_384;
36
37/// Upper bound on any `_s` seconds interval. 1 day is already far
38/// beyond any reasonable value; anything larger is almost certainly a
39/// unit-conversion error (someone thought the field was in ms).
40pub const MAX_SECONDS_INTERVAL: u64 = 24 * 60 * 60;
41
42/// Per-phase timeout applied to both the worker `wait_idle` drain and
43/// the `finalize_sessions` sweep in `finish_shutdown`. Exposed on the
44/// public surface so the deployment-manifest pin tests below can
45/// compute the total shutdown budget from the SAME source of truth
46/// `main.rs` uses — a bump here that isn't matched by a bump in the
47/// K8s / compose grace periods trips CI at build time.
48pub const WORKER_FINALIZE_PHASE_SECS: u64 = 30;
49
50/// Deadline the OpenTelemetry provider gets to flush pending spans on
51/// shutdown when the `otel` feature is enabled. Same "single source of
52/// truth" purpose as [`WORKER_FINALIZE_PHASE_SECS`].
53pub const OTEL_FLUSH_SECS: u64 = 5;
54
55/// Default upstream read-idle timeout. Successful reads restart this
56/// timer; it is not a total deadline for streamed chat responses.
57pub const DEFAULT_UPSTREAM_READ_TIMEOUT_S: u64 = 60;
58
59/// Total deadline for an outbound MCP request, including its response body.
60pub const DEFAULT_MCP_REQUEST_TIMEOUT_S: u64 = 60;
61
62/// Top-level harness configuration.
63///
64/// Unknown keys are rejected (`deny_unknown_fields`) so a typo like
65/// `idel_timeout_s` fails loudly at startup instead of being silently
66/// ignored. Forward compatibility is handled by `config_version` gating,
67/// not by tolerating unrecognized keys.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct HarnessConfig {
71    /// Config format version.
72    #[serde(default = "default_config_version")]
73    pub config_version: u32,
74    /// Listen address, e.g. `127.0.0.1:8484`.
75    #[serde(default = "default_listen")]
76    pub listen: String,
77    /// Upstream LLM provider base URL (OpenAI-compatible).
78    pub upstream_url: String,
79    /// Optional downstream MCP/REST tool server URL. When absent, `/v1/mcp`
80    /// operates as a policy decision endpoint.
81    #[serde(default)]
82    pub tool_upstream_url: Option<String>,
83    /// Use cleartext HTTP/2 prior knowledge for trusted h2c upstreams.
84    #[serde(default)]
85    pub upstream_http2_prior_knowledge: bool,
86    /// Optional upstream read-idle timeout override (default 60 s).
87    /// Successful reads restart this timer. It does not bound total
88    /// streamed-chat duration. Capped at one day like every `_s` interval.
89    #[serde(default)]
90    pub upstream_read_timeout_s: Option<u64>,
91    /// Total outbound MCP deadline, including response-body reads. The
92    /// default is 60 s; this also bounds tool work after caller cancellation.
93    /// This does not impose a total deadline on streamed chat responses.
94    #[serde(default)]
95    pub mcp_request_timeout_s: Option<u64>,
96    /// Graceful-shutdown drain budget in seconds. When unset the
97    /// effective budget is `max(30, max(upstream_read_timeout_s,
98    /// mcp_request_timeout_s) + 5)`, using each field's default when unset.
99    /// This covers one read-idle window or MCP network deadline with
100    /// padding; long chat streams and audit work may exceed it.
101    /// Kubernetes users must keep `terminationGracePeriodSeconds`
102    /// above this value or the kubelet SIGKILLs mid-drain.
103    #[serde(default)]
104    pub shutdown_drain_timeout_s: Option<u64>,
105    /// Readiness-controlled pre-drain window in seconds (default 0).
106    /// On SIGTERM/SIGINT the harness flips `/readyz` to 503 and then
107    /// KEEPS ACCEPTING connections for this long before the graceful
108    /// drain begins, so an external load balancer polling `/readyz`
109    /// can stop routing before the listener closes. Without it the
110    /// listener stops accepting the instant the signal lands and a
111    /// fresh readiness probe sees connection-refused, never the 503.
112    /// Set it to your LB's readiness poll interval plus one
113    /// reconciliation. Counts against the orchestrator's kill grace
114    /// period ON TOP of the drain budget.
115    ///
116    /// Kubernetes note: a preStop `sleep` hook can theoretically
117    /// provide the same window BEFORE SIGTERM lands, but only if
118    /// the runtime image contains a shell + `sleep`. The shipped
119    /// manifest at `deploy/kubernetes/proofvisor.yaml` uses a
120    /// distroless base (chainguard/glibc-dynamic) that has neither,
121    /// so it sets this key instead and omits the preStop hook.
122    /// Bare-VM, systemd, and docker-compose deployments also need
123    /// this key because they have no preStop equivalent at all.
124    #[serde(default)]
125    pub shutdown_ready_drain_s: u64,
126    /// Chat-completions path appended to `upstream_url`. Override for
127    /// providers with non-standard layouts (Azure deployments, Gemini's
128    /// OpenAI-compatible surface).
129    #[serde(default = "default_chat_path")]
130    pub upstream_chat_path: String,
131    /// The upstream provider's wire dialect.
132    /// Selects the `ProviderAdapter` that parses response bodies and
133    /// SSE chunks. `"openai"` (the default) also fits vLLM, LiteLLM,
134    /// Groq, Together, DeepSeek, OpenRouter, Ollama, LM Studio,
135    /// llama.cpp, xAI, Mistral and Azure OpenAI.
136    #[serde(default = "default_provider")]
137    pub provider: String,
138    /// Anthropic's Messages API requires `max_tokens` on every request.
139    /// When an OpenAI request carries neither `max_completion_tokens`
140    /// nor `max_tokens`, the `anthropic` provider sends this value
141    /// (default 4096). Other providers ignore it.
142    #[serde(default = "default_provider_max_tokens")]
143    pub provider_default_max_tokens: u64,
144    /// Name of an environment variable holding the upstream API key. The
145    /// key value itself never appears in this file or on the command line.
146    #[serde(default)]
147    pub upstream_api_key_env: Option<String>,
148    /// File containing the upstream API key (owner-only permissions are
149    /// enforced on Unix). Mutually exclusive with `upstream_api_key_env`.
150    #[serde(default)]
151    pub upstream_api_key_file: Option<String>,
152    /// Header carrying the upstream API key, e.g. `authorization` (OpenAI),
153    /// `api-key` (Azure), or `x-api-key`.
154    #[serde(default = "default_auth_header")]
155    pub upstream_auth_header: String,
156    /// Prefix inserted before the key in the auth header. `"Bearer"` yields
157    /// `Bearer <key>`; an empty string sends the raw key (Azure style).
158    #[serde(default = "default_auth_scheme")]
159    pub upstream_auth_scheme: String,
160    /// Forward each client's own `Authorization` header to the upstream
161    /// instead of injecting a server-side key. Incompatible with
162    /// `require_identity` (the header would carry the NHI token) and with
163    /// the static key options above.
164    #[serde(default)]
165    pub upstream_authorization_passthrough: bool,
166    /// Accept and DISCARD a client `Authorization` header when no identity
167    /// validator is configured. This is the "hero snippet" mode: every
168    /// stock OpenAI SDK requires an `api_key` and sends
169    /// `Authorization: Bearer <it>` unconditionally, but a dev harness
170    /// with no validator used to hard-401 that request — making the
171    /// documented one-line quickstart fail on request one. With this
172    /// flag the header is treated as if absent: the request proceeds
173    /// anonymously, the header is NEVER forwarded upstream (the
174    /// server-side key is injected instead), and nothing is recorded
175    /// from it. Refused in combination with `require_identity = true`
176    /// (the validator must see the header) and with
177    /// `upstream_authorization_passthrough = true` (contradictory:
178    /// cannot both discard and forward). `pvctl init` writes this for
179    /// dev presets so the README quickstart works out of the box.
180    #[serde(default)]
181    pub ignore_client_authorization: bool,
182    /// Name of an environment variable holding a bearer token for
183    /// `tool_upstream_url` requests.
184    #[serde(default)]
185    pub tool_upstream_bearer_env: Option<String>,
186    /// File containing a bearer token for `tool_upstream_url` requests
187    /// (owner-only permissions enforced on Unix).
188    #[serde(default)]
189    pub tool_upstream_bearer_file: Option<String>,
190    /// Identity enforcement. When `false` (dev), requests without a token get
191    /// an anonymous identity; when `true`, unauthenticated requests are 401s.
192    #[serde(default)]
193    pub require_identity: bool,
194    /// Explicit opt-in for binding to a wildcard address (`0.0.0.0` / `[::]`)
195    /// while `require_identity = false`. Set this only when a network layer
196    /// outside the harness — a container port map, a service mesh, an
197    /// ingress ACL — controls who can reach the listener. Startup validation
198    /// otherwise refuses the combination so a bare-metal `cargo run` on a
199    /// developer laptop cannot silently expose an anonymous provider proxy
200    /// to a corporate LAN.
201    #[serde(default)]
202    pub allow_wildcard_bind: bool,
203    /// Deployment audience for NHI tokens.
204    #[serde(default = "default_audience")]
205    pub audience: String,
206    /// Corporate IdP JWKS endpoint for Ed25519 verification keys.
207    #[serde(default)]
208    pub identity_jwks_url: Option<String>,
209    /// JWKS refresh interval in seconds.
210    #[serde(default = "default_jwks_refresh")]
211    pub identity_jwks_refresh_s: u64,
212    /// Optional issuer allowlist (for example Okta or Entra tenant URLs).
213    #[serde(default)]
214    pub identity_allowed_issuers: Vec<String>,
215    /// Optional file containing an HS256 development secret.
216    #[serde(default)]
217    pub identity_hmac_secret_file: Option<String>,
218    /// Key id assigned to the development HMAC secret.
219    #[serde(default = "default_hmac_kid")]
220    pub identity_hmac_kid: String,
221    /// Maximum delegation depth for NHI token chains. Controls both
222    /// `parent_token` chain links and `act` claim nesting. Defaults to 4.
223    #[serde(default = "default_max_delegation_depth")]
224    pub max_delegation_depth: usize,
225    /// Regular expression every validated token's `sub` must match, so
226    /// each agent identity is attributable to a human principal (for
227    /// example `^user:`). Tokens whose subject does not match are refused
228    /// with 401. Unset (the default) accepts any subject.
229    #[serde(default)]
230    pub identity_human_subject_pattern: Option<String>,
231
232    /// Enforce operation scopes on validated identities.
233    ///
234    /// Default flipped from `true` to `false` so it
235    /// matches the also-default-`false` posture of
236    /// [`Self::require_identity`]. When `require_identity = false`,
237    /// unauthenticated requests short-circuit to the anonymous
238    /// identity BEFORE the scope gate runs — an operator who reads
239    /// `enforce_identity_scopes = true` in the config would
240    /// reasonably conclude "you need the scope to reach /v1/chat",
241    /// but in the default posture curl-with-no-header still
242    /// proceeds as anonymous. `validate()` now rejects the
243    /// `enforce_identity_scopes = true && require_identity = false`
244    /// combination outright; keeping the two defaults aligned makes
245    /// the shipped `harness.example.toml` and `harness.container.toml`
246    /// pass validate without extra changes. Operators turning on
247    /// identity enforcement in production set both flags to `true`
248    /// explicitly (see `harness.docker.toml`).
249    #[serde(default)]
250    pub enforce_identity_scopes: bool,
251    /// Scope required for chat completion requests.
252    #[serde(default = "default_chat_scope")]
253    pub chat_scope: String,
254    /// Scope required to close sessions.
255    #[serde(default = "default_close_scope")]
256    pub session_close_scope: String,
257    /// Scope required to promote unsigned sessions.
258    #[serde(default = "default_promote_scope")]
259    pub session_promote_scope: String,
260    /// Default workflow when `X-PV-Workflow` is absent: signed workflows are
261    /// opt-in by policy (brief Module G).
262    #[serde(default = "default_workflow")]
263    pub default_workflow: String,
264    /// Tools that require a signed workflow because they have real-world
265    /// consequences.
266    #[serde(default = "default_consequential_tools")]
267    pub consequential_tools: Vec<String>,
268    /// Directory containing one JSON Schema per tool, named `<tool>.json`.
269    #[serde(default = "default_tool_schema_dir")]
270    pub tool_schema_dir: Option<String>,
271    /// Reject tool calls when no matching schema was loaded.
272    #[serde(default = "default_true")]
273    pub require_tool_schema: bool,
274    /// Console-pushed policy bundle, written by `pvctl console-sync
275    /// --watch --pull-policies` on this host. When set, the daemon
276    /// applies the file at boot (it refuses to start if the file exists
277    /// but cannot be applied), polls it every `policy_bundle_poll_s`, and
278    /// writes what it enforces to `<file>.status.json`. Pushed policies
279    /// only tighten the local config: budgets take the stricter cap,
280    /// allowlists and schemas apply on top of local ones, and rate
281    /// policies limit tokens per principal. Unset: nothing is pulled.
282    #[serde(default)]
283    pub policy_bundle_file: Option<String>,
284    /// Seconds between checks of `policy_bundle_file` (1–3600).
285    #[serde(default = "default_policy_bundle_poll_s")]
286    pub policy_bundle_poll_s: u64,
287    /// Tool-call argument field carrying a payout amount in USD, charged
288    /// against `budget.max_payout_usd_micros`. This was a
289    /// hardcoded `"amount_usd"` buried in the sandbox constructor — a
290    /// tool using `amount`, `value`, or `total_usd` silently bypassed
291    /// the payout cap with no warning. Set this to match YOUR tool
292    /// schema; the cap only fires on calls that carry this exact field.
293    #[serde(default = "default_payout_field")]
294    pub payout_field: String,
295    /// WASM or WAT policy module paths, evaluated in order.
296    #[serde(default = "default_wasm_policies")]
297    pub wasm_policy_paths: Vec<String>,
298    /// Idle seconds after which a session is swept closed.
299    #[serde(default = "default_idle")]
300    pub session_idle_close_s: u64,
301    /// Directory for ATIF trajectory spool files.
302    #[serde(default = "default_spool")]
303    pub atif_spool_dir: String,
304    /// Bridge data directory (embedded broker).
305    #[serde(default = "default_bridge")]
306    pub bridge_data_dir: String,
307    /// Bridge backend: `embedded`, `kafka`, or `nats`.
308    #[serde(default = "default_bridge_backend")]
309    pub bridge_backend: String,
310    /// Declarative topic-schema manifest used by every Bridge backend.
311    #[serde(default = "default_bridge_manifest")]
312    pub bridge_manifest_path: String,
313    /// Kafka broker (`host:port[,host:port]`) or NATS URL (`nats://`/`tls://`)
314    /// for network Bridge backends. Secured endpoints read their material
315    /// from the environment: `PV_KAFKA_CA_FILE` + `PV_KAFKA_SASL_USERNAME`/
316    /// `PV_KAFKA_SASL_PASSWORD` (+ optional `PV_KAFKA_SASL_MECHANISM`:
317    /// `SCRAM-SHA-256` default, `SCRAM-SHA-512`, or `PLAIN`; credentials
318    /// are refused without the CA), and `PV_NATS_CA_FILE` (forces TLS) +
319    /// `PV_NATS_USER`/`PV_NATS_PASSWORD`.
320    #[serde(default)]
321    pub bridge_endpoint: Option<String>,
322    /// State backend: `memory` or `redis`.
323    #[serde(default = "default_state_backend")]
324    pub state_backend: String,
325    /// Redis URL for the distributed state backend. A comma-separated list
326    /// of URLs selects Redis Cluster mode.
327    #[serde(default)]
328    pub state_endpoint: Option<String>,
329    /// Embedding backend: `hash` or `onnx`.
330    #[serde(default = "default_embedder_backend")]
331    pub embedder_backend: String,
332    /// Customer-supplied ONNX model path.
333    #[serde(default)]
334    pub onnx_model_path: Option<String>,
335    /// Hugging Face tokenizer.json paired with the ONNX model.
336    #[serde(default)]
337    pub onnx_tokenizer_path: Option<String>,
338    /// ONNX model output width.
339    #[serde(default = "default_onnx_dimension")]
340    pub onnx_dimension: usize,
341    /// Vector persistence backend: `memory` or `qdrant`.
342    #[serde(default = "default_vector_backend")]
343    pub vector_backend: String,
344    /// Qdrant base URL.
345    #[serde(default)]
346    pub qdrant_url: Option<String>,
347    /// Qdrant collection receiving reasoning vectors.
348    #[serde(default = "default_qdrant_collection")]
349    pub qdrant_collection: String,
350    /// Worker channel capacity (bounded; overflow is counted, never blocking).
351    #[serde(default = "default_channel_cap")]
352    pub worker_channel_capacity: usize,
353    /// Max concurrent `mcp_call_inner` executions. Each call buffers
354    /// up to `MAX_TOOL_RESPONSE_BYTES = 16 MiB` (routes.rs), so the
355    /// admission cap × 16 MiB bounds worst-case MCP resident memory.
356    /// R70 F1 (landed R71): without this cap a slow-tool-upstream
357    /// attack + N concurrent MCP callers stacked N × 16 MiB in RAM
358    /// unbounded — the mcp_inflight tracker was a shutdown-drain
359    /// COUNTER, not a limiter.
360    ///
361    /// Default 128 (~2 GiB worst case). The `MAX_MCP_CONCURRENCY`
362    /// cap below refuses any config value above 4 096 for the same
363    /// class-safety reason as `MAX_WORKER_CHANNEL_CAPACITY`.
364    #[serde(default = "default_mcp_concurrency")]
365    pub mcp_concurrency: usize,
366    /// Strict per-stage budget assertions (PV_STRICT_BUDGET also enables).
367    #[serde(default)]
368    pub strict_stage_budget: bool,
369    /// Loop breaker configuration.
370    #[serde(default)]
371    pub breaker: pv_loopdetect::BreakerConfig,
372    /// Compression configuration.
373    #[serde(default = "default_compression")]
374    pub compression_enabled: bool,
375    /// Token budget per session (compression/velocity accounting).
376    #[serde(default)]
377    pub budget: pv_state::BudgetSpec,
378    /// Optional principal-scoped budget layered on top of [`Self::budget`].
379    ///
380    /// The default `[budget]` counters are keyed on the request's session
381    /// id — a header the caller chooses (see [`Self::require_identity`]).
382    /// A client that rotates `X-PV-Session` per request lands on a virgin
383    /// counter every time, so `budget.max_tokens` and friends never bind.
384    /// Setting `[principal_budget]` layers a second budget keyed on the
385    /// authenticated principal (the JWT `sub` / `instance_uid`, or the
386    /// HMAC key id); the counters accumulate across every session belonging
387    /// to that principal, so header rotation debits the same ledger.
388    ///
389    /// Startup validation refuses this section when
390    /// `require_identity = false` unless `allow_anonymous_principal_budget
391    /// = true` is also set: without a stable principal every request folds
392    /// into a single `"anonymous"` bucket, which is only useful in a
393    /// single-tenant appliance deployment where that is the desired
394    /// behaviour.
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub principal_budget: Option<pv_state::BudgetSpec>,
397    /// Opt-in for `[principal_budget]` while `require_identity = false`.
398    /// See the field-level doc on [`Self::principal_budget`].
399    #[serde(default)]
400    pub allow_anonymous_principal_budget: bool,
401    /// Reconciler tick interval (seconds).
402    #[serde(default = "default_reconcile_tick")]
403    pub reconcile_tick_s: u64,
404    /// Days after which sealed ATIF trajectories and their `.atif-auth`
405    /// provenance sidecars are pruned from the spool. `None` (the shipping
406    /// default) preserves prior behaviour — the ATIF spool grows without
407    /// bound, matching an on-prem deployment that manages retention with
408    /// an external cron. Setting a positive value enables a periodic
409    /// prune sweep that removes pairs older than the window, keeping the
410    /// reconciler's per-tick scan cost bounded even for high-throughput
411    /// deployments.
412    #[serde(default, skip_serializing_if = "Option::is_none")]
413    pub atif_retention_days: Option<u32>,
414    /// Maximum request body size accepted on `/v1/chat/completions` and
415    /// `/mcp`. Defaults to 4 MiB, matching the sandbox's `MAX_PAYLOAD_BYTES`
416    /// so both routes carry the same effective limit — axum's own
417    /// `DefaultBodyLimit::MAX` is 2 MiB by default and would silently
418    /// reject legitimate large-context chat requests before the sandbox
419    /// even saw the payload. Operators serving very-long-context models
420    /// (Claude 200k, GPT-4 128k on maximally-verbose inputs) may need to
421    /// raise this.
422    #[serde(default = "default_max_request_bytes")]
423    pub max_request_bytes: usize,
424
425    /// Whether the built-in read-only operator dashboard is enabled.
426    ///
427    /// When true (the default), the harness serves:
428    ///   * `GET /dashboard` — an HTML/CSS/JS single-page dashboard,
429    ///   * `GET /dashboard/{style.css,app.js}` — the bundled assets,
430    ///   * `GET /api/v1/dashboard/{stats,sessions,sessions/:id}` — read-only
431    ///     JSON that mirrors the in-memory session registry.
432    ///
433    /// The endpoints are unauthenticated: they expose the same data that
434    /// already lands on disk (receipts) and in `/metrics`. Front the
435    /// harness with the same ingress control you use for `/metrics` if
436    /// this is a concern, or set this to `false` to disable them.
437    #[serde(default = "default_dashboard_enabled")]
438    pub dashboard_enabled: bool,
439
440    /// Host-header allowlist (DNS-rebinding defense).
441    ///
442    /// Empty (the default) disables the check — correct for
443    /// loopback-only binds and for deployments whose ingress already
444    /// enforces Host. When non-empty, every request whose `Host`
445    /// header (port stripped) is not in this list is refused with 403
446    /// BEFORE any handler runs. DNS rebinding lets a hostile page's
447    /// JavaScript reach a loopback-adjacent service under a hostname
448    /// the attacker controls; browser CSRF protections don't apply
449    /// because the resolved IP is ours while the Host is theirs —
450    /// pinning the expected hostnames closes it. Example:
451    /// `allowed_hosts = ["localhost", "127.0.0.1", "proofvisor.internal"]`.
452    #[serde(default)]
453    pub allowed_hosts: Vec<String>,
454
455    // ── Pillar 4: per-backend routing ────────────────────────────────
456    /// Named backends that MCP tool calls can be routed to. Each entry
457    /// defines a name, URL, and auth policy. When absent, the single
458    /// `tool_upstream_url` is used as an implicit "default" backend.
459    #[serde(default)]
460    pub backends: Vec<BackendConfig>,
461
462    /// Refuse to connect to any tool backend (including `tool_upstream_url`)
463    /// whose address is not private: loopback, RFC 1918, shared (RFC 6598),
464    /// link-local, or IPv6 unique-local. Host names are resolved and checked
465    /// on every connection; IP literals are checked at boot. The LLM
466    /// upstream and the AuthZEN PDP are not affected.
467    #[serde(default)]
468    pub require_private_backends: bool,
469
470    /// Wire protocol for the implicit backend built from
471    /// `tool_upstream_url`. Defaults to `json_rpc` (one plain JSON-RPC POST
472    /// per call, the historical behavior); set `mcp` when that upstream is
473    /// an MCP Streamable HTTP server. `[[backends]]` entries choose their
474    /// own `transport` and default to `mcp`.
475    #[serde(default = "default_tool_upstream_transport")]
476    pub tool_upstream_transport: BackendTransport,
477
478    /// Browser origins allowed to call the MCP endpoints. The MCP
479    /// Streamable HTTP transport requires servers to refuse requests whose
480    /// `Origin` header is present and not allowed (DNS-rebinding defense).
481    /// Agents run server-side and send no `Origin`, so the default (empty)
482    /// refuses every request that carries one. Entries are exact origins,
483    /// for example `https://console.example.com`.
484    #[serde(default)]
485    pub mcp_allowed_origins: Vec<String>,
486
487    // ── Pillar 2: intent mapping / AuthZEN ───────────────────────────
488    /// Tool-name → business-intent mapping. An unmapped tool is refused
489    /// with `UNMAPPED_TOOL` when `require_intent_mapping` is true.
490    #[serde(default)]
491    pub intent_map: std::collections::BTreeMap<String, String>,
492
493    /// When true, every tool call must have an entry in `intent_map` or
494    /// it is refused with `UNMAPPED_TOOL`. Defaults to false for
495    /// backward compatibility.
496    #[serde(default)]
497    pub require_intent_mapping: bool,
498
499    /// Active mission constraints. A mission narrows the static policy:
500    /// only the listed intents are permitted while the mission is active.
501    /// When absent, all mapped intents are permitted.
502    #[serde(default)]
503    pub mission: Option<MissionConfig>,
504
505    /// Per-agent missions (TOML `[[missions]]`). Each entry names the
506    /// agents, charters, or human principals it applies to. A call must
507    /// satisfy the global `[mission]` and every mission that selects its
508    /// caller, so missions only ever narrow the static policy.
509    #[serde(default)]
510    pub missions: Vec<MissionConfig>,
511
512    /// Intent token TTL in seconds. Defaults to 60 (the AuthZEN
513    /// recommendation for short-lived per-call tokens).
514    #[serde(default = "default_intent_token_ttl")]
515    pub intent_token_ttl_s: u64,
516
517    /// Platform workload identity (TOML `[workload_identity]`): accept RFC
518    /// 7523 assertions signed with a Cloud Foundry instance identity at
519    /// `/v1/token` and issue agent tokens to the registered workloads.
520    #[serde(default)]
521    pub workload_identity: Option<WorkloadIdentityConfig>,
522
523    /// Workloads that may obtain agent tokens (TOML `[[workload_identities]]`).
524    /// Each names the human principal its agents act for.
525    #[serde(default)]
526    pub workload_identities: Vec<WorkloadMapping>,
527
528    /// External AuthZEN policy decision point consulted on every tool call
529    /// after the local intent and mission checks permit it (TOML
530    /// `[authzen]`). Both must permit. Unreachable PDP → 503.
531    #[serde(default)]
532    pub authzen: Option<AuthzenConfig>,
533
534    // ── Pillar 3: token exchange ─────────────────────────────────────
535    /// Enable the RFC 8693 token exchange endpoint at
536    /// `/v1/token`. When true, the harness signs exchanged tokens
537    /// with a dedicated Ed25519 key (separate from the receipt signer).
538    #[serde(default)]
539    pub token_exchange_enabled: bool,
540
541    /// File containing the Ed25519 signing seed for token exchange.
542    /// Must be distinct from the receipt signing seed. Owner-only
543    /// permissions enforced on Unix.
544    #[serde(default)]
545    pub token_exchange_seed_file: Option<String>,
546
547    /// TTL in seconds for exchanged tokens. Defaults to 300 (five
548    /// minutes). Capped at 900 to prevent indefinite delegation.
549    #[serde(default = "default_exchange_ttl")]
550    pub token_exchange_ttl_s: u64,
551
552    /// Named operator credentials authorized for administrative revocation.
553    /// Only SHA-256 digests are stored in configuration.
554    #[serde(default)]
555    pub operator_tokens: Vec<OperatorTokenConfig>,
556
557    /// Separate backend credentials for the token introspection endpoint.
558    #[serde(default)]
559    pub introspection_tokens: Vec<IntrospectionTokenConfig>,
560
561    /// Maximum revocation audit events per minute. Defaults to 600.
562    #[serde(default = "default_revocation_audit_per_minute")]
563    pub revocation_audit_per_minute: u32,
564
565    // ── Pillar 6: OTEL per-tenant ────────────────────────────────────
566    /// Per-tenant OTEL exporter endpoint. When set, full-fidelity
567    /// traces are shipped to the customer's own collector.
568    #[serde(default)]
569    pub otel_tenant_endpoint: Option<String>,
570
571    /// File containing the bearer token for the tenant OTEL endpoint.
572    /// Owner-only permissions enforced on Unix.
573    #[serde(default)]
574    pub otel_tenant_auth_file: Option<String>,
575
576    /// What the tenant OTEL endpoint receives beyond request metadata.
577    /// `redacted` (the default) also exports every audited step as an
578    /// OpenTelemetry GenAI span with its content (prompts, outputs, tool
579    /// arguments and results), after redaction; `off` exports metadata
580    /// spans only. Content never goes to the operational collector.
581    #[serde(default)]
582    pub otel_tenant_content: TenantContent,
583
584    /// Sensitive-data redaction patterns (regex). Applied to event
585    /// payloads and ATIF step fields before journal write.
586    ///
587    /// Redaction runs only when this list or `redaction_paths` is
588    /// non-empty; both empty (the serde default) means nothing is
589    /// redacted. Whenever it runs, the `pv-redact` built-in patterns
590    /// (Bearer tokens, `sk-*`/`key-*` API keys, email addresses,
591    /// Luhn-valid card numbers, US Social Security numbers, public IPv4
592    /// addresses) are always included on top of the patterns listed
593    /// here. Every shipped config and `pvctl init` set this to
594    /// [`RECOMMENDED_REDACTION_PATTERNS`].
595    #[serde(default)]
596    pub redaction_patterns: Vec<String>,
597
598    /// JSON pointer paths to always redact in event payloads and structured
599    /// ATIF fields. Setting either paths or patterns also enables builtins.
600    #[serde(default)]
601    pub redaction_paths: Vec<String>,
602}
603
604/// The `redaction_patterns` value every shipped config and `pvctl init`
605/// write, so that redaction is on out of the box.
606///
607/// The engine only runs when `redaction_patterns` or `redaction_paths`
608/// is non-empty, and it always adds the `pv-redact` built-in patterns
609/// once it runs. This list therefore does two jobs: it turns the
610/// built-in set on, and it extends it with well-known credential
611/// formats the built-ins do not cover. Setting `redaction_patterns = []`
612/// (with no `redaction_paths`) turns redaction off entirely.
613///
614/// Patterns use `[0-9]`/`[A-Za-z]` and `(?-u:\b)` for the same reason
615/// the built-ins do: `\d` and `\b` are Unicode-aware in the regex crate.
616/// None contains a single quote, so they can be written as TOML literal
617/// strings (`'...'`) without escaping.
618pub const RECOMMENDED_REDACTION_PATTERNS: &[&str] = &[
619    // AWS access key IDs (long-lived `AKIA...`, temporary `ASIA...`).
620    r"(?-u:\b)(?:AKIA|ASIA)[0-9A-Z]{16}(?-u:\b)",
621    // GitHub personal, OAuth, server-to-server, user-to-server, and
622    // refresh tokens (`ghp_`, `gho_`, `ghs_`, `ghu_`, `ghr_`).
623    r"(?-u:\b)gh[opsur]_[A-Za-z0-9]{36,255}(?-u:\b)",
624    // Google API keys.
625    r"(?-u:\b)AIza[0-9A-Za-z_-]{35}(?-u:\b)",
626    // Slack bot, user, app, refresh, and legacy tokens.
627    r"(?-u:\b)xox[abeprs]-[A-Za-z0-9-]{10,255}",
628    // JSON Web Tokens that appear without a `Bearer ` prefix (the
629    // built-in Bearer rule only catches the prefixed form).
630    r"(?-u:\b)eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}",
631];
632
633/// Operator credential for administrative revocation.
634#[derive(Debug, Clone, Serialize, Deserialize)]
635#[serde(deny_unknown_fields)]
636pub struct OperatorTokenConfig {
637    /// Unique operator name recorded in administrative audit events.
638    pub name: String,
639    /// SHA-256 of the bearer token, encoded as 64 hexadecimal characters.
640    pub sha256: String,
641}
642
643/// Backend credential for introspection, separate from upstream credentials.
644#[derive(Debug, Clone, Serialize, Deserialize)]
645#[serde(deny_unknown_fields)]
646pub struct IntrospectionTokenConfig {
647    /// Configured backend name whose tokens this credential may inspect.
648    pub backend: String,
649    /// SHA-256 of the bearer token, encoded as 64 hexadecimal characters.
650    pub sha256: String,
651}
652
653fn default_revocation_audit_per_minute() -> u32 {
654    600
655}
656
657/// Per-backend routing configuration (pillar 4).
658#[derive(Debug, Clone, Serialize, Deserialize)]
659#[serde(deny_unknown_fields)]
660pub struct BackendConfig {
661    /// Unique backend name (used in intent mapping and metrics).
662    pub name: String,
663    /// Base URL of the backend MCP server.
664    pub url: String,
665    /// Auth policy for this backend.
666    #[serde(default = "default_backend_auth")]
667    pub auth: BackendAuth,
668    /// Tools routed to this backend. When empty, this backend serves
669    /// as the default for unmapped tools.
670    #[serde(default)]
671    pub tools: Vec<String>,
672    /// Wire protocol spoken to this backend. `mcp` (the default) is the
673    /// MCP Streamable HTTP transport: an `initialize` handshake per agent
674    /// session, `MCP-Session-Id` and `MCP-Protocol-Version` headers, and
675    /// SSE responses. `json_rpc` sends one plain JSON-RPC POST per call.
676    #[serde(default)]
677    pub transport: BackendTransport,
678}
679
680/// Wire protocol spoken to a tool backend.
681#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
682#[serde(rename_all = "snake_case")]
683pub enum BackendTransport {
684    /// MCP Streamable HTTP (protocol versions 2024-11-05 through 2025-11-25).
685    #[default]
686    Mcp,
687    /// One plain JSON-RPC 2.0 POST per call, no MCP session handshake.
688    JsonRpc,
689}
690
691fn default_tool_upstream_transport() -> BackendTransport {
692    BackendTransport::JsonRpc
693}
694
695/// An allowed browser origin is `scheme://host[:port]` with no path,
696/// query, fragment, or credentials (RFC 6454 serialization).
697pub(crate) fn validate_origin(origin: &str) -> Result<(), String> {
698    let parsed = reqwest::Url::parse(origin).map_err(|_| "not a valid origin".to_owned())?;
699    if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none_or(str::is_empty) {
700        return Err("must be an http:// or https:// origin with a host".to_owned());
701    }
702    if !parsed.username().is_empty()
703        || parsed.password().is_some()
704        || parsed.query().is_some()
705        || parsed.fragment().is_some()
706        || origin.trim_end_matches('/') != parsed.origin().ascii_serialization()
707    {
708        return Err("must be exactly scheme://host[:port], without path, query, or credentials".to_owned());
709    }
710    Ok(())
711}
712
713/// Authentication mode for a backend.
714#[derive(Debug, Clone, Default, Serialize, Deserialize)]
715#[serde(rename_all = "snake_case")]
716pub enum BackendAuth {
717    /// No authentication.
718    #[default]
719    None,
720    /// Static bearer token from an environment variable.
721    StaticEnv(String),
722    /// Static bearer token from a file (owner-only permissions enforced).
723    StaticFile(String),
724    /// Exchange the inbound NHI token for a backend-scoped token via
725    /// the harness's token exchange service. The exchanged token
726    /// carries `aud` set to this backend's name.
727    Exchange,
728    /// Forward the caller's own validated bearer token to this backend,
729    /// and to no other. Requires `require_identity = true`. The backend
730    /// receives a token whose audience is the gateway, so it could replay
731    /// that token against the gateway; use it only for backends you trust
732    /// as much as the gateway, and prefer `exchange` otherwise.
733    ForwardToken,
734}
735
736/// Mission configuration: narrows the static policy for a time window.
737#[derive(Debug, Clone, Serialize, Deserialize)]
738#[serde(deny_unknown_fields)]
739pub struct MissionConfig {
740    /// Mission identifier.
741    pub id: String,
742    /// Permitted intents during this mission. Tool calls whose mapped
743    /// intent is not in this list are refused with `MISSION_DENIED`.
744    pub allowed_intents: Vec<String>,
745    /// Mission expiry as epoch seconds. After this time, all tool calls
746    /// are refused with `MISSION_EXPIRED`.
747    pub expires_at: u64,
748    /// `[[missions]]` only: agents (token `instance_uid`) this mission
749    /// applies to.
750    #[serde(default, skip_serializing_if = "Vec::is_empty")]
751    pub agents: Vec<String>,
752    /// `[[missions]]` only: agent charters this mission applies to.
753    #[serde(default, skip_serializing_if = "Vec::is_empty")]
754    pub charters: Vec<String>,
755    /// `[[missions]]` only: human principals (token `sub`) this mission
756    /// applies to.
757    #[serde(default, skip_serializing_if = "Vec::is_empty")]
758    pub subjects: Vec<String>,
759}
760
761impl MissionConfig {
762    /// True when at least one selector is set (a per-agent mission).
763    pub fn has_selectors(&self) -> bool {
764        !self.agents.is_empty() || !self.charters.is_empty() || !self.subjects.is_empty()
765    }
766
767    /// True when this per-agent mission applies to the caller: every set
768    /// selector must match. A mission without selectors selects nobody
769    /// here (the global `[mission]` applies to everyone by itself).
770    pub fn selects(&self, instance_uid: &str, charter: &str, subject: Option<&str>) -> bool {
771        self.has_selectors()
772            && (self.agents.is_empty() || self.agents.iter().any(|agent| agent == instance_uid))
773            && (self.charters.is_empty() || self.charters.iter().any(|name| name == charter))
774            && (self.subjects.is_empty() || subject.is_some_and(|sub| self.subjects.iter().any(|s| s == sub)))
775    }
776}
777
778/// `[workload_identity]` settings.
779#[derive(Debug, Clone, Serialize, Deserialize)]
780#[serde(deny_unknown_fields)]
781pub struct WorkloadIdentityConfig {
782    /// PEM bundle of the platform's instance-identity root CA certificates.
783    pub ca_file: String,
784    /// Lifetime of issued agent tokens in seconds (60 to 900, default 300).
785    #[serde(default = "default_workload_token_ttl")]
786    pub token_ttl_s: u64,
787}
788
789fn default_workload_token_ttl() -> u64 {
790    300
791}
792
793/// One `[[workload_identities]]` entry.
794#[derive(Debug, Clone, Serialize, Deserialize)]
795#[serde(deny_unknown_fields)]
796pub struct WorkloadMapping {
797    /// Unique name (metrics and logs).
798    pub name: String,
799    /// Cloud Foundry app GUID the instance must belong to.
800    #[serde(default)]
801    pub cf_app_guid: Option<String>,
802    /// Cloud Foundry space GUID the instance must belong to.
803    #[serde(default)]
804    pub cf_space_guid: Option<String>,
805    /// Cloud Foundry organization GUID the instance must belong to.
806    #[serde(default)]
807    pub cf_org_guid: Option<String>,
808    /// The human principal the workload's agents act for (token `sub`).
809    pub sub: String,
810    /// Agent charter recorded in the token and every audit event.
811    pub charter: String,
812    /// Agent version recorded in the token.
813    #[serde(default = "default_workload_version")]
814    pub version: String,
815    /// Scopes granted to the workload's agents.
816    pub scopes: Vec<String>,
817}
818
819fn default_workload_version() -> String {
820    "1".to_owned()
821}
822
823impl WorkloadMapping {
824    /// True when every set selector matches the proven identity.
825    pub fn selects(&self, identity: &pv_identity::CfInstanceIdentity) -> bool {
826        let matches =
827            |expected: &Option<String>, actual: &str| expected.as_deref().is_none_or(|value| value == actual);
828        (self.cf_app_guid.is_some() || self.cf_space_guid.is_some() || self.cf_org_guid.is_some())
829            && matches(&self.cf_app_guid, &identity.app_guid)
830            && matches(&self.cf_space_guid, &identity.space_guid)
831            && matches(&self.cf_org_guid, &identity.org_guid)
832    }
833}
834
835/// Content exported to the tenant OTEL endpoint.
836#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
837#[serde(rename_all = "snake_case")]
838pub enum TenantContent {
839    /// Full step content as GenAI spans, redacted before export.
840    #[default]
841    Redacted,
842    /// Request metadata spans only.
843    Off,
844}
845
846/// External AuthZEN policy decision point
847/// ([Authorization API 1.0](https://openid.net/specs/authorization-api-1_0.html)).
848#[derive(Debug, Clone, Serialize, Deserialize)]
849#[serde(deny_unknown_fields)]
850pub struct AuthzenConfig {
851    /// Access Evaluation endpoint, e.g.
852    /// `https://pdp.example.com/access/v1/evaluation`.
853    pub evaluation_endpoint: String,
854    /// Optional Access Evaluations (batch) endpoint, e.g.
855    /// `https://pdp.example.com/access/v1/evaluations`, used to filter
856    /// `tools/list` in one request. Without it, `tools/list` sends one
857    /// evaluation per tool.
858    #[serde(default)]
859    pub evaluations_endpoint: Option<String>,
860    /// File holding a bearer token for the PDP (owner-only permissions).
861    #[serde(default)]
862    pub auth_file: Option<String>,
863    /// Per-request timeout in milliseconds (1 to 10 000, default 1 000).
864    /// A timeout or error refuses the call with 503 (fail closed).
865    #[serde(default = "default_authzen_timeout_ms")]
866    pub timeout_ms: u64,
867}
868
869fn default_authzen_timeout_ms() -> u64 {
870    1_000
871}
872
873fn default_max_delegation_depth() -> usize {
874    4
875}
876fn default_intent_token_ttl() -> u64 {
877    60
878}
879fn default_exchange_ttl() -> u64 {
880    300
881}
882fn default_backend_auth() -> BackendAuth {
883    BackendAuth::None
884}
885fn default_config_version() -> u32 {
886    CONFIG_VERSION
887}
888fn default_listen() -> String {
889    "127.0.0.1:8484".to_owned()
890}
891// Wire value: workload identity tokens are minted for this audience and
892// the daemon signs intent/exchange tokens with it as `iss`. Deployments
893// that never set `audience` depend on it, so it keeps its pre-rename
894// value.
895fn default_audience() -> String {
896    "agentvisor-ai".to_owned()
897}
898fn default_chat_path() -> String {
899    "/v1/chat/completions".to_owned()
900}
901fn default_auth_header() -> String {
902    "authorization".to_owned()
903}
904fn default_auth_scheme() -> String {
905    "Bearer".to_owned()
906}
907fn default_jwks_refresh() -> u64 {
908    300
909}
910fn default_hmac_kid() -> String {
911    "dev-hmac".to_owned()
912}
913fn default_chat_scope() -> String {
914    "chat:write".to_owned()
915}
916fn default_close_scope() -> String {
917    "session:close".to_owned()
918}
919fn default_promote_scope() -> String {
920    "session:promote".to_owned()
921}
922fn default_workflow() -> String {
923    crate::session::Workflow::Unsigned.as_str().to_owned()
924}
925fn default_consequential_tools() -> Vec<String> {
926    ["db_write", "payout", "merge", "deploy"]
927        .into_iter()
928        .map(str::to_owned)
929        .collect()
930}
931fn default_tool_schema_dir() -> Option<String> {
932    Some("config/tool-schemas".to_owned())
933}
934fn default_true() -> bool {
935    true
936}
937fn default_policy_bundle_poll_s() -> u64 {
938    5
939}
940fn default_payout_field() -> String {
941    "amount_usd".to_owned()
942}
943fn default_wasm_policies() -> Vec<String> {
944    vec!["config/policies/payload_limit.wat".to_owned()]
945}
946fn default_idle() -> u64 {
947    900
948}
949fn default_spool() -> String {
950    "spool/atif".to_owned()
951}
952fn default_bridge() -> String {
953    "data/bridge".to_owned()
954}
955fn default_bridge_backend() -> String {
956    "embedded".to_owned()
957}
958fn default_provider() -> String {
959    "openai".to_owned()
960}
961fn default_provider_max_tokens() -> u64 {
962    4096
963}
964fn default_bridge_manifest() -> String {
965    "manifests/bridge.example.yaml".to_owned()
966}
967fn default_state_backend() -> String {
968    "memory".to_owned()
969}
970fn default_embedder_backend() -> String {
971    "hash".to_owned()
972}
973fn default_onnx_dimension() -> usize {
974    384
975}
976fn default_vector_backend() -> String {
977    "memory".to_owned()
978}
979fn default_qdrant_collection() -> String {
980    "agent_steps".to_owned()
981}
982fn default_channel_cap() -> usize {
983    32_768
984}
985fn default_mcp_concurrency() -> usize {
986    128
987}
988fn default_compression() -> bool {
989    true
990}
991fn default_reconcile_tick() -> u64 {
992    5
993}
994fn default_max_request_bytes() -> usize {
995    4 * 1024 * 1024
996}
997fn default_dashboard_enabled() -> bool {
998    true
999}
1000
1001/// Typed bridge backend selection, resolved from the flat TOML fields.
1002///
1003/// The TOML wire format deliberately keeps the shipped flat keys
1004/// (`bridge_backend = "kafka"` + `bridge_endpoint = …`): this is a
1005/// public tool with configs in the field, and a `config_version` bump
1006/// that strands them is a hazard. Everything downstream of parsing
1007/// works with these enums instead — each variant carries its required
1008/// companions, so "kafka without an endpoint" is unrepresentable once
1009/// resolved. The per-selector accessors on [`HarnessConfig`]
1010/// ([`HarnessConfig::bridge`] and friends) are the SINGLE site that
1011/// owns the legal-value vocabulary and the required-companion rules;
1012/// `validate()` and the daemon's backend factories both delegate to
1013/// them (previously the four selectors were `String`s whose
1014/// legal values were enumerated twice, with companion rules spread
1015/// across four more sites).
1016#[derive(Debug, Clone, PartialEq, Eq)]
1017pub enum BridgeBackend {
1018    /// Embedded broker persisting under `bridge_data_dir`.
1019    Embedded,
1020    /// Kafka/Redpanda; `endpoint` is the `host:port[,host:port]`
1021    /// bootstrap list (not a URL — no scheme check applies).
1022    Kafka {
1023        /// Bootstrap list, `host:port[,host:port]`.
1024        endpoint: String,
1025    },
1026    /// NATS JetStream; `endpoint` is a `nats://` or `tls://` URL.
1027    Nats {
1028        /// `nats://` or `tls://` URL.
1029        endpoint: String,
1030    },
1031}
1032
1033/// Typed state backend selection. See [`BridgeBackend`] for why the
1034/// TOML surface stays flat while the resolved form is an enum.
1035#[derive(Debug, Clone, PartialEq, Eq)]
1036pub enum StateBackend {
1037    /// Process-local, non-durable counters.
1038    Memory,
1039    /// Redis / Redis Cluster; `endpoint` is one URL or a
1040    /// comma-separated list (cluster mode).
1041    Redis {
1042        /// Redis URL, or a comma-separated list for cluster mode.
1043        endpoint: String,
1044    },
1045}
1046
1047/// Typed embedding backend selection. See [`BridgeBackend`].
1048#[derive(Debug, Clone, PartialEq, Eq)]
1049pub enum EmbedderBackend {
1050    /// Deterministic hash embedder (no model files).
1051    Hash,
1052    /// Customer-supplied ONNX model plus its Hugging Face tokenizer.
1053    Onnx {
1054        /// Path to the ONNX model file.
1055        model_path: String,
1056        /// Path to the paired Hugging Face `tokenizer.json`.
1057        tokenizer_path: String,
1058    },
1059}
1060
1061/// Typed vector persistence backend selection. See [`BridgeBackend`].
1062#[derive(Debug, Clone, PartialEq, Eq)]
1063pub enum VectorBackend {
1064    /// No persistence (in-memory no-op sink).
1065    Memory,
1066    /// Qdrant; `url` is the http(s) base URL.
1067    Qdrant {
1068        /// Qdrant base URL (http:// or https://).
1069        url: String,
1070    },
1071}
1072
1073/// All four backend selections resolved into their typed forms.
1074#[derive(Debug, Clone, PartialEq, Eq)]
1075pub struct ResolvedBackends {
1076    /// Resolved bridge (event bus) backend.
1077    pub bridge: BridgeBackend,
1078    /// Resolved state store backend.
1079    pub state: StateBackend,
1080    /// Resolved embedding backend.
1081    pub embedder: EmbedderBackend,
1082    /// Resolved vector persistence backend.
1083    pub vector: VectorBackend,
1084}
1085
1086/// Where the effective configuration came from.
1087#[derive(Debug, Clone, PartialEq, Eq)]
1088pub enum ConfigSource {
1089    /// Loaded from a TOML file on disk.
1090    File(std::path::PathBuf),
1091    /// Built-in defaults (zero-config mode; requires `PV_UPSTREAM_URL`).
1092    BuiltIn,
1093}
1094
1095impl std::fmt::Display for ConfigSource {
1096    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1097        match self {
1098            Self::File(path) => write!(f, "{}", path.display()),
1099            Self::BuiltIn => f.write_str("built-in defaults (zero-config)"),
1100        }
1101    }
1102}
1103
1104/// Well-known config locations probed in order when `PV_CONFIG` is unset.
1105/// Project-local files (current directory) beat the per-user file the
1106/// `pvctl` setup wizard writes (see [`user_config_path`]).
1107///
1108/// Note: `config/harness.example.toml` is deliberately NOT in this list.
1109/// The example config is a template intended to be *copied* to a real
1110/// `proofvisor.toml` or `config/harness.toml`; auto-discovering it from
1111/// a working checkout silently defeats the wizard-written per-user file
1112/// (developers who ran `pvctl init` still saw the example's settings
1113/// because the example outranked the wizard file).
1114/// `agentvisor.toml` is the name before the ProofVisor rename; it is tried
1115/// last so an installation that has both files uses the new one.
1116pub const CONFIG_SEARCH_PATHS: [&str; 3] = ["proofvisor.toml", "config/harness.toml", "agentvisor.toml"];
1117
1118/// Per-user config file inside `home`, as written by the `pvctl` wizard.
1119pub fn user_config_path_from(home: &std::path::Path) -> std::path::PathBuf {
1120    // An installation made before the ProofVisor rename has only
1121    // ~/.agentvisor/agentvisor.toml; keep finding it.
1122    pv_core::legacy::existing_or_legacy(
1123        home.join(".proofvisor").join("proofvisor.toml"),
1124        &home.join(".agentvisor").join("agentvisor.toml"),
1125    )
1126}
1127
1128/// Per-user config file (`~/.proofvisor/proofvisor.toml`), if the home
1129/// directory is known.
1130pub fn user_config_path() -> Option<std::path::PathBuf> {
1131    std::env::home_dir().map(|home| user_config_path_from(&home))
1132}
1133
1134/// Resolve the configuration source without reading it.
1135///
1136/// Order: `PV_CONFIG` (must exist — a typo must never silently fall
1137/// through to another file), then [`CONFIG_SEARCH_PATHS`], then the
1138/// per-user wizard file, then built-in defaults driven by
1139/// `PV_UPSTREAM_URL`.
1140pub fn resolve_config_source() -> Result<ConfigSource, String> {
1141    if let Some(path) = std::env::var_os("PV_CONFIG") {
1142        // Empty means unset: compose files commonly render
1143        // `PV_CONFIG: ${PV_CONFIG:-}` which must not become a hard error.
1144        if !path.is_empty() {
1145            let path = std::path::PathBuf::from(path);
1146            if !path.is_file() {
1147                return Err(format!(
1148                    "PV_CONFIG points to {} which does not exist or is not a file",
1149                    path.display()
1150                ));
1151            }
1152            return Ok(ConfigSource::File(path));
1153        }
1154    }
1155    for candidate in CONFIG_SEARCH_PATHS {
1156        let path = std::path::Path::new(candidate);
1157        if path.is_file() {
1158            return Ok(ConfigSource::File(path.to_path_buf()));
1159        }
1160    }
1161    if let Some(path) = user_config_path() {
1162        if path.is_file() {
1163            return Ok(ConfigSource::File(path));
1164        }
1165    }
1166    Ok(ConfigSource::BuiltIn)
1167}
1168
1169/// Load, apply environment overrides, and validate the effective config.
1170pub fn load_config() -> Result<(HarnessConfig, ConfigSource), String> {
1171    load_config_with_override(None)
1172}
1173
1174/// [`load_config`] with an explicit config path (e.g. from `--config`)
1175/// taking precedence over `$PV_CONFIG` and the search paths. The explicit
1176/// path must exist — a dangling operator-supplied path is a hard error,
1177/// never a silent fallback to a different config.
1178pub fn load_config_with_override(
1179    explicit: Option<std::path::PathBuf>,
1180) -> Result<(HarnessConfig, ConfigSource), String> {
1181    let source = match explicit {
1182        Some(path) => {
1183            if !path.is_file() {
1184                return Err(format!(
1185                    "--config points to {} which does not exist or is not a file",
1186                    path.display()
1187                ));
1188            }
1189            ConfigSource::File(path)
1190        }
1191        None => resolve_config_source()?,
1192    };
1193    let mut config = match &source {
1194        ConfigSource::File(path) => {
1195            // Same MAX_CONTROL_BYTES cap as `pvctl config-validate`, so
1196            // the offline validator and the daemon reach the same verdict
1197            // on the same file (and a mispointed path at a huge file
1198            // cannot balloon boot memory).
1199            let text = pv_core::fsutil::read_capped_string(
1200                std::path::Path::new(path),
1201                pv_core::fsutil::MAX_CONTROL_BYTES,
1202            )
1203            .map_err(|error| format!("read harness config {}: {error}", path.display()))?;
1204            HarnessConfig::from_toml_unvalidated(&text)
1205                .map_err(|error| format!("{}: {error}", path.display()))?
1206        }
1207        ConfigSource::BuiltIn => HarnessConfig::builtin()?,
1208    };
1209    config.apply_env_overrides();
1210    if config.upstream_url.is_empty() && source == ConfigSource::BuiltIn {
1211        return Err(format!(
1212            "no configuration found and PV_UPSTREAM_URL is not set.\n\
1213             Quick start (pick one):\n\
1214             \x20 pvctl                              # guided setup\n\
1215             \x20 pvctl init --preset openai        # write an annotated proofvisor.toml\n\
1216             \x20 PV_UPSTREAM_URL=http://127.0.0.1:11434 proofvisord   # zero-config\n\
1217             Searched: $PV_CONFIG, {}, ~/.proofvisor/proofvisor.toml",
1218            CONFIG_SEARCH_PATHS.join(", ")
1219        ));
1220    }
1221    config.validate().map_err(|error| format!("{source}: {error}"))?;
1222    Ok((config, source))
1223}
1224
1225impl HarnessConfig {
1226    /// Read the optional tenant telemetry bearer token using the same
1227    /// owner-only secret-file checks as backend credentials.
1228    pub fn tenant_otel_authorization(&self) -> Result<Option<axum::http::HeaderValue>, String> {
1229        let token = crate::pipeline::read_secret(
1230            None,
1231            self.otel_tenant_auth_file.as_deref(),
1232            "tenant OTEL bearer token",
1233        )
1234        .map_err(|error| error.to_string())?;
1235        token
1236            .map(|token| {
1237                let mut header =
1238                    axum::http::HeaderValue::from_str(&format!("Bearer {token}")).map_err(|_| {
1239                        "tenant OTEL bearer token cannot be represented as an HTTP header".to_owned()
1240                    })?;
1241                header.set_sensitive(true);
1242                Ok(header)
1243            })
1244            .transpose()
1245    }
1246
1247    /// Parse from TOML, validating the version and structural sanity.
1248    pub fn from_toml(s: &str) -> Result<Self, String> {
1249        Self::check_declared_version(s)?;
1250        let cfg: Self = toml::from_str(s).map_err(|e| format!("config parse: {e}"))?;
1251        cfg.validate()?;
1252        Ok(cfg)
1253    }
1254
1255    /// Parse from TOML without validating, so environment overrides can be
1256    /// applied first (`main` validates after [`Self::apply_env_overrides`]).
1257    pub fn from_toml_unvalidated(s: &str) -> Result<Self, String> {
1258        Self::check_declared_version(s)?;
1259        toml::from_str(s).map_err(|e| format!("config parse: {e}"))
1260    }
1261
1262    /// Pre-pass on the loosely-parsed document so a config written for a
1263    /// newer format version reports "unsupported config_version N" instead
1264    /// of tripping the strict unknown-field rejection on whatever new key
1265    /// appears first.
1266    fn check_declared_version(s: &str) -> Result<(), String> {
1267        let loose: toml::Value = toml::from_str(s).map_err(|e| format!("config parse: {e}"))?;
1268        if let Some(version) = loose.get("config_version") {
1269            let declared = version.as_integer().unwrap_or(-1);
1270            if declared != i64::from(CONFIG_VERSION) {
1271                return Err(format!(
1272                    "unsupported config_version {version} (this build supports {CONFIG_VERSION})",
1273                ));
1274            }
1275        }
1276        Ok(())
1277    }
1278
1279    /// A config of pure built-in defaults for zero-config startup. The
1280    /// caller must supply `upstream_url` (typically `PV_UPSTREAM_URL`)
1281    /// before validation.
1282    pub fn builtin() -> Result<Self, String> {
1283        toml::from_str("upstream_url = \"\"").map_err(|e| format!("built-in config: {e}"))
1284    }
1285
1286    /// True when `bridge_manifest_path` is the compiled-in default, i.e.
1287    /// the operator never chose a manifest. Only then may the binary fall
1288    /// back to its embedded manifest when the file is absent; an explicit
1289    /// path that is missing must stay a hard error.
1290    pub fn uses_default_manifest_path(&self) -> bool {
1291        self.bridge_manifest_path == default_bridge_manifest()
1292    }
1293
1294    /// True when `tool_schema_dir` is the compiled-in default (see
1295    /// [`Self::uses_default_manifest_path`] for the fallback rationale).
1296    pub fn uses_default_tool_schema_dir(&self) -> bool {
1297        self.tool_schema_dir == default_tool_schema_dir()
1298    }
1299
1300    /// True when `path` is the compiled-in default WASM policy entry (see
1301    /// [`Self::uses_default_manifest_path`] for the fallback rationale).
1302    pub fn is_default_policy_path(path: &str) -> bool {
1303        default_wasm_policies().iter().any(|entry| entry == path)
1304    }
1305
1306    /// Effective graceful-shutdown drain budget. Explicit
1307    /// `shutdown_drain_timeout_s` wins; otherwise allow the greater of one
1308    /// upstream read-idle window and the total MCP network deadline, plus
1309    /// five seconds, with a 30-second floor. Long streamed chat responses
1310    /// and audit work are still subject to this shutdown deadline.
1311    pub fn effective_drain_timeout(&self) -> std::time::Duration {
1312        let read_timeout_s = self
1313            .upstream_read_timeout_s
1314            .unwrap_or(DEFAULT_UPSTREAM_READ_TIMEOUT_S);
1315        let mcp_timeout_s = self
1316            .mcp_request_timeout_s
1317            .unwrap_or(DEFAULT_MCP_REQUEST_TIMEOUT_S);
1318        let seconds = self
1319            .shutdown_drain_timeout_s
1320            .unwrap_or_else(|| read_timeout_s.max(mcp_timeout_s).saturating_add(5).max(30));
1321        std::time::Duration::from_secs(seconds)
1322    }
1323
1324    /// Detect the classic footgun of `upstream_url` already ending with the
1325    /// first segment of `upstream_chat_path` (for example a base URL of
1326    /// `https://api.openai.com/v1` joined with `/v1/chat/completions`
1327    /// produces `/v1/v1/...` and a confusing provider 404). Returns the
1328    /// duplicated segment for warning messages.
1329    pub fn duplicated_chat_path_segment(&self) -> Option<&str> {
1330        // The worst variant first: the base URL embeds the entire chat path
1331        // (a pasted full endpoint URL), so the join repeats all of it.
1332        if !self.upstream_chat_path.is_empty()
1333            && self
1334                .upstream_url
1335                .trim_end_matches('/')
1336                .ends_with(&self.upstream_chat_path)
1337        {
1338            return Some(self.upstream_chat_path.trim_start_matches('/'));
1339        }
1340        let first_segment = self
1341            .upstream_chat_path
1342            .trim_start_matches('/')
1343            .split('/')
1344            .next()?;
1345        if first_segment.is_empty() {
1346            return None;
1347        }
1348        let base = self.upstream_url.trim_end_matches('/');
1349        let last_segment = base.rsplit('/').next()?;
1350        // A bare scheme+host has no path segments; ignore the host itself.
1351        if last_segment.contains('.') || last_segment.contains(':') || base.ends_with("//") {
1352            return None;
1353        }
1354        (last_segment == first_segment).then_some(first_segment)
1355    }
1356
1357    /// Apply `PV_*` environment overrides from the process environment.
1358    /// Environment beats file for these scalars (12-factor container
1359    /// deployments override without editing mounted files). Key *values*
1360    /// are still never read here — only `PV_UPSTREAM_API_KEY` presence
1361    /// selects itself as the key source.
1362    pub fn apply_env_overrides(&mut self) {
1363        self.apply_env_overrides_from(|name| std::env::var(name).ok());
1364    }
1365
1366    /// Testable core of [`Self::apply_env_overrides`].
1367    pub fn apply_env_overrides_from(&mut self, get: impl Fn(&str) -> Option<String>) {
1368        let non_empty = |value: String| if value.is_empty() { None } else { Some(value) };
1369        if let Some(listen) = get("PV_LISTEN").and_then(non_empty) {
1370            self.listen = listen;
1371        }
1372        if let Some(url) = get("PV_UPSTREAM_URL").and_then(non_empty) {
1373            self.upstream_url = url;
1374        }
1375        if let Some(path) = get("PV_UPSTREAM_CHAT_PATH").and_then(non_empty) {
1376            self.upstream_chat_path = path;
1377        }
1378        if let Some(header) = get("PV_UPSTREAM_AUTH_HEADER").and_then(non_empty) {
1379            self.upstream_auth_header = header;
1380        }
1381        // Empty string is meaningful here: raw-key (schemeless) headers.
1382        if let Some(scheme) = get("PV_UPSTREAM_AUTH_SCHEME") {
1383            self.upstream_auth_scheme = scheme;
1384        }
1385        if let Some(endpoint) = get("PV_STATE_ENDPOINT").and_then(non_empty) {
1386            self.state_endpoint = Some(endpoint);
1387        }
1388        if let Some(endpoint) = get("PV_BRIDGE_ENDPOINT").and_then(non_empty) {
1389            self.bridge_endpoint = Some(endpoint);
1390        }
1391        if let Some(url) = get("PV_QDRANT_URL").and_then(non_empty) {
1392            self.qdrant_url = Some(url);
1393        }
1394        // Docker/Kubernetes secrets arrive as mounted files; let those
1395        // deployments point at one without editing config. File beats the
1396        // PV_UPSTREAM_API_KEY convenience below but never a config-file
1397        // key source (validate() rejects env+file ambiguity anyway).
1398        if self.upstream_api_key_env.is_none()
1399            && self.upstream_api_key_file.is_none()
1400            && !self.upstream_authorization_passthrough
1401        {
1402            if let Some(path) = get("PV_UPSTREAM_KEY_FILE").and_then(non_empty) {
1403                self.upstream_api_key_file = Some(path);
1404            }
1405        }
1406        // Convenience: exporting PV_UPSTREAM_API_KEY selects itself as the
1407        // key source unless the file already chose one (file wins so a
1408        // stray environment variable cannot silently replace a configured
1409        // source; validate() still rejects genuinely ambiguous configs).
1410        if self.upstream_api_key_env.is_none()
1411            && self.upstream_api_key_file.is_none()
1412            && !self.upstream_authorization_passthrough
1413            && get("PV_UPSTREAM_API_KEY").and_then(non_empty).is_some()
1414        {
1415            self.upstream_api_key_env = Some("PV_UPSTREAM_API_KEY".to_owned());
1416        }
1417    }
1418
1419    /// A required companion field for a non-default backend: present and
1420    /// non-empty, or the caller's named error.
1421    fn required_companion<'a>(value: Option<&'a str>, error: &str) -> Result<&'a str, String> {
1422        value.filter(|v| !v.is_empty()).ok_or_else(|| error.to_owned())
1423    }
1424
1425    /// Resolve `bridge_backend` + `bridge_endpoint` into the typed
1426    /// [`BridgeBackend`]. This is the single site owning the legal-value
1427    /// vocabulary and the required-companion rule for the bridge
1428    /// selector; `validate()` and the daemon's bridge factory both
1429    /// delegate here, so "kafka without an endpoint" cannot survive
1430    /// parsing.
1431    pub fn bridge(&self) -> Result<BridgeBackend, String> {
1432        const ENDPOINT_REQUIRED: &str = "bridge_endpoint is required for kafka and nats backends";
1433        match self.bridge_backend.as_str() {
1434            "embedded" => Ok(BridgeBackend::Embedded),
1435            "kafka" => Ok(BridgeBackend::Kafka {
1436                endpoint: Self::required_companion(self.bridge_endpoint.as_deref(), ENDPOINT_REQUIRED)?
1437                    .to_owned(),
1438            }),
1439            "nats" => Ok(BridgeBackend::Nats {
1440                endpoint: Self::required_companion(self.bridge_endpoint.as_deref(), ENDPOINT_REQUIRED)?
1441                    .to_owned(),
1442            }),
1443            other => Err(format!(
1444                "bridge_backend must be embedded|kafka|nats, got {other:?}"
1445            )),
1446        }
1447    }
1448
1449    /// Resolve `state_backend` + `state_endpoint` into the typed
1450    /// [`StateBackend`] (see [`Self::bridge`] for the single-parse-site
1451    /// rationale).
1452    pub fn state(&self) -> Result<StateBackend, String> {
1453        match self.state_backend.as_str() {
1454            "memory" => Ok(StateBackend::Memory),
1455            "redis" => Ok(StateBackend::Redis {
1456                endpoint: Self::required_companion(
1457                    self.state_endpoint.as_deref(),
1458                    "state_endpoint is required for the redis backend",
1459                )?
1460                .to_owned(),
1461            }),
1462            other => Err(format!("state_backend must be memory|redis, got {other:?}")),
1463        }
1464    }
1465
1466    /// Resolve `embedder_backend` + the ONNX companion paths into the
1467    /// typed [`EmbedderBackend`] (see [`Self::bridge`]).
1468    pub fn embedder(&self) -> Result<EmbedderBackend, String> {
1469        const PATHS_REQUIRED: &str =
1470            "onnx_model_path and onnx_tokenizer_path are required for the onnx backend";
1471        match self.embedder_backend.as_str() {
1472            "hash" => Ok(EmbedderBackend::Hash),
1473            "onnx" => Ok(EmbedderBackend::Onnx {
1474                model_path: Self::required_companion(self.onnx_model_path.as_deref(), PATHS_REQUIRED)?
1475                    .to_owned(),
1476                tokenizer_path: Self::required_companion(
1477                    self.onnx_tokenizer_path.as_deref(),
1478                    PATHS_REQUIRED,
1479                )?
1480                .to_owned(),
1481            }),
1482            other => Err(format!("embedder_backend must be hash|onnx, got {other:?}")),
1483        }
1484    }
1485
1486    /// Resolve `vector_backend` + `qdrant_url` into the typed
1487    /// [`VectorBackend`] (see [`Self::bridge`]).
1488    pub fn vector(&self) -> Result<VectorBackend, String> {
1489        match self.vector_backend.as_str() {
1490            "memory" => Ok(VectorBackend::Memory),
1491            "qdrant" => Ok(VectorBackend::Qdrant {
1492                url: Self::required_companion(
1493                    self.qdrant_url.as_deref(),
1494                    "qdrant_url is required for the qdrant vector backend",
1495                )?
1496                .to_owned(),
1497            }),
1498            other => Err(format!("vector_backend must be memory|qdrant, got {other:?}")),
1499        }
1500    }
1501
1502    /// Resolve all four backend selectors at once. Errors accumulate —
1503    /// one entry per selector that fails to resolve — matching
1504    /// `validate()`'s report-everything behaviour.
1505    pub fn backends(&self) -> Result<ResolvedBackends, Vec<String>> {
1506        let mut errors = Vec::new();
1507        let bridge = self.bridge().map_err(|error| errors.push(error)).ok();
1508        let state = self.state().map_err(|error| errors.push(error)).ok();
1509        let embedder = self.embedder().map_err(|error| errors.push(error)).ok();
1510        let vector = self.vector().map_err(|error| errors.push(error)).ok();
1511        match (bridge, state, embedder, vector) {
1512            (Some(bridge), Some(state), Some(embedder), Some(vector)) => Ok(ResolvedBackends {
1513                bridge,
1514                state,
1515                embedder,
1516                vector,
1517            }),
1518            _ => Err(errors),
1519        }
1520    }
1521
1522    /// Backends this configuration selects that the *current build*
1523    /// cannot run because the required cargo feature was compiled out
1524    /// (`pvctl config-validate` once reported "valid" for
1525    /// `bridge_backend = "kafka"` on a default-features binary; the
1526    /// daemon then hard-failed at boot — neither pre-flight tool knew
1527    /// what the binary could actually run). Returns one message per
1528    /// unsatisfiable selection; empty means every configured backend
1529    /// is compiled in. `validate()` deliberately does NOT fold this
1530    /// in: shape validity and build capability are different
1531    /// questions (a config can be valid for a `--features full`
1532    /// daemon while the pvctl doing the pre-flight was built lean).
1533    /// Selections that fail to resolve into their typed backend are
1534    /// skipped here — `validate()` already refuses them, and every
1535    /// caller (daemon boot, doctor, config-validate) validates first.
1536    #[must_use]
1537    pub fn unsupported_backend_requirements(&self) -> Vec<String> {
1538        let mut missing = Vec::new();
1539        let mut require = |enabled: bool, field: &str, value: &str, feature: &str| {
1540            if !enabled {
1541                missing.push(format!(
1542                    "{field} = {value:?} requires the `{feature}` cargo feature, \
1543                     which this build was compiled without (rebuild with \
1544                     --features {feature} or full)"
1545                ));
1546            }
1547        };
1548        match self.bridge() {
1549            Ok(BridgeBackend::Kafka { .. }) => {
1550                require(cfg!(feature = "kafka"), "bridge_backend", "kafka", "kafka");
1551            }
1552            Ok(BridgeBackend::Nats { .. }) => {
1553                require(cfg!(feature = "nats"), "bridge_backend", "nats", "nats");
1554            }
1555            Ok(BridgeBackend::Embedded) | Err(_) => {}
1556        }
1557        if matches!(self.state(), Ok(StateBackend::Redis { .. })) {
1558            require(cfg!(feature = "redis"), "state_backend", "redis", "redis");
1559        }
1560        if matches!(self.embedder(), Ok(EmbedderBackend::Onnx { .. })) {
1561            require(cfg!(feature = "onnx"), "embedder_backend", "onnx", "onnx");
1562        }
1563        if matches!(self.vector(), Ok(VectorBackend::Qdrant { .. })) {
1564            require(cfg!(feature = "qdrant"), "vector_backend", "qdrant", "qdrant");
1565        }
1566        if self.otel_tenant_endpoint.is_some() {
1567            require(cfg!(feature = "otel"), "otel_tenant_endpoint", "<set>", "otel");
1568        }
1569        missing
1570    }
1571
1572    /// Structural validation. Returns the single violation verbatim
1573    /// (historical error shape) or, for multiple violations, a
1574    /// numbered aggregate naming all of them.
1575    pub fn validate(&self) -> Result<(), String> {
1576        if self.config_version != CONFIG_VERSION {
1577            // Short-circuit: a config written for a different format
1578            // version makes every other check meaningless noise.
1579            return Err(format!(
1580                "unsupported config_version {} (this build supports {CONFIG_VERSION})",
1581                self.config_version
1582            ));
1583        }
1584        let mut errors = Vec::new();
1585        self.collect_validation_errors(&mut errors);
1586        if errors.len() > 1 {
1587            return Err(format!(
1588                "{} config errors:\n  - {}",
1589                errors.len(),
1590                errors.join("\n  - ")
1591            ));
1592        }
1593        match errors.pop() {
1594            None => Ok(()),
1595            Some(error) => Err(error),
1596        }
1597    }
1598
1599    /// Structural validation, one push per violation:
1600    /// every check reports independently so a single `pvctl
1601    /// config-validate` run surfaces the complete list — previously
1602    /// only the first of 60+ checks was reported per round-trip.
1603    /// Checks after a failed one still run; they operate on owned
1604    /// string/number fields, so a prior violation can at worst add a
1605    /// redundant message, never a panic.
1606    fn collect_validation_errors(&self, errors: &mut Vec<String>) {
1607        if self.listen.is_empty() {
1608            errors.push("listen is required (host:port, e.g. 127.0.0.1:8484)".into());
1609        }
1610        // Shape-only check: hostnames are resolved at bind time, but a missing
1611        // or non-numeric port would otherwise pass validation and only fail at
1612        // server startup, defeating pre-flight `pvctl config-validate`/doctor.
1613        match self.listen.rsplit_once(':') {
1614            Some((host, port)) if !host.is_empty() && port.parse::<u16>().is_ok() => {}
1615            _ => {
1616                errors.push(format!(
1617                    "listen {:?} must be host:port with a port in 0-65535",
1618                    self.listen
1619                ));
1620            }
1621        }
1622        // Refuse an all-interfaces bind with no authentication. `0.0.0.0` /
1623        // `[::]` / `*` on a machine reachable by anyone but the operator
1624        // gives every peer a fully-authenticated proxy against the operator's
1625        // provider key, plus the unauthenticated dashboard when it is on.
1626        // The escape hatch is explicit: set `require_identity = true` (and
1627        // configure a JWKS/HMAC source), pin `listen` to a loopback address,
1628        // or set `allow_wildcard_bind = true` — the last is for container /
1629        // service-mesh deployments where an outer network layer already
1630        // controls who reaches the listener.
1631        if !self.require_identity && !self.allow_wildcard_bind {
1632            let host = self
1633                .listen
1634                .rsplit_once(':')
1635                .map(|(h, _)| h.trim_start_matches('[').trim_end_matches(']'))
1636                .unwrap_or("");
1637            // Parse-and-classify instead of literal string matching: the
1638            // unspecified address has many spellings (`::0`,
1639            // `0:0:0:0:0:0:0:0`, `::ffff:0.0.0.0`, …) and a literal list
1640            // silently waves the non-canonical ones through — the exact
1641            // all-interfaces exposure this guard exists to refuse.
1642            // `to_canonical()` first: the v4-mapped form `::ffff:0.0.0.0`
1643            // parses as an Ipv6Addr whose own `is_unspecified()` is false,
1644            // yet binding it produces the same any-interface listener as
1645            // `::` (kernels treat the mapped-zero as INADDR_ANY).
1646            let is_wildcard = matches!(host, "*" | "")
1647                || host
1648                    .parse::<std::net::IpAddr>()
1649                    .is_ok_and(|ip| ip.to_canonical().is_unspecified());
1650            if is_wildcard {
1651                errors.push(format!(
1652                    "listen {:?} binds every interface while require_identity = false: any \
1653                     peer reaching this host would speak to the proxy anonymously with the \
1654                     operator's provider credentials. Either pin listen to a loopback / \
1655                     private-network address, set require_identity = true with an \
1656                     identity_jwks_url / identity_hmac_secret_file, or set \
1657                     allow_wildcard_bind = true if an outer network layer (container port \
1658                     map, service mesh, ingress ACL) controls who reaches the listener.",
1659                    self.listen
1660                ));
1661            }
1662        }
1663        // The dashboard shares the public listener and carries NO
1664        // identity gate of its own — `require_identity = true` protects
1665        // the proxy API but the dashboard routes still hand any peer
1666        // the session list, agent identities, and complete receipts.
1667        // On a wildcard bind that is a tenant-data disclosure, not a
1668        // dev convenience; require the same explicit
1669        // `allow_wildcard_bind` acknowledgment the anonymous-proxy
1670        // guard above uses (an outer network layer controlling access
1671        // covers both concerns), or turn the dashboard off.
1672        if self.dashboard_enabled && self.require_identity && !self.allow_wildcard_bind {
1673            let host = self
1674                .listen
1675                .rsplit_once(':')
1676                .map(|(h, _)| h.trim_start_matches('[').trim_end_matches(']'))
1677                .unwrap_or("");
1678            let is_wildcard = matches!(host, "*" | "")
1679                || host
1680                    .parse::<std::net::IpAddr>()
1681                    .is_ok_and(|ip| ip.to_canonical().is_unspecified());
1682            if is_wildcard {
1683                errors.push(format!(
1684                    "listen {:?} binds every interface with dashboard_enabled = true: the \
1685                     dashboard has no identity gate (require_identity covers only the proxy \
1686                     API), so any peer can enumerate sessions and download receipts. Set \
1687                     dashboard_enabled = false, pin listen, or set allow_wildcard_bind = \
1688                     true if an outer network layer controls who reaches the listener.",
1689                    self.listen
1690                ));
1691            }
1692        }
1693        if self.reconcile_tick_s == 0 {
1694            errors.push("reconcile_tick_s must be greater than zero".into());
1695        }
1696        if self.session_idle_close_s == 0 {
1697            errors.push(
1698                "session_idle_close_s must be greater than zero (0 would close every open session at each reconcile tick)"
1699                    .into(),
1700            );
1701        }
1702        // `atif_retention_days = Some(0)` would deploy a daemon whose
1703        // hourly sweep computes `max_age = Duration::ZERO`; the sweep
1704        // predicate is `age < max_age`, which is false for every sealed
1705        // pair, so every sealed `<stem>.json` + `.atif-auth` +
1706        // `.close-complete` triple is deleted on the first tick — total
1707        // loss of the pre-signing evidence chain. `None` (the ship
1708        // default) disables retention; the CLI `pvctl spool-prune
1709        // --retention-days 0` path is a separate, one-off decommission
1710        // command and is intentionally not gated here.
1711        if self.atif_retention_days == Some(0) {
1712            errors.push(
1713                "atif_retention_days = 0 would delete every sealed ATIF pair on the first hourly sweep; \
1714                 omit the field to disable retention, or set >= 1 to enable an N-day window"
1715                    .into(),
1716            );
1717        }
1718        // R69 F3 (landed R70): `BudgetSpec` numeric caps of `Some(0)`
1719        // are silently accepted by the ledger — `try_spend` refuses
1720        // every `amount > 0` against a `limit == 0`, so
1721        // `budget.max_tokens = 0` under the default `require_identity
1722        // = false` shape 429-storms every non-empty chat request; the
1723        // operator symptom is "every request 429s" and the exact
1724        // diagnostic hunt `pvctl config-validate` is meant to
1725        // short-circuit. `max_tool_calls["db_write"] = 0` silently
1726        // disables that tool with no config warning. The unset-vs-
1727        // zero divergence for `max_payout_usd_micros` produces two
1728        // subtly different `BudgetDecision::Refused` messages with
1729        // identical policy semantics — surface zero at config time.
1730        //
1731        // The campaign added the same `== 0` fail-loud check for
1732        // every other numeric interval / capacity field
1733        // (`identity_jwks_refresh_s`, `worker_channel_capacity`,
1734        // `reconcile_tick_s`, `session_idle_close_s`,
1735        // `upstream_read_timeout_s`, `shutdown_drain_timeout_s`,
1736        // `atif_retention_days == Some(0)` above, `max_request_bytes`,
1737        // `onnx_dimension`, `breaker.window`). BudgetSpec was the
1738        // odd one out.
1739        Self::validate_budget_spec(&self.budget, "budget", errors);
1740        if let Some(pb) = &self.principal_budget {
1741            Self::validate_budget_spec(pb, "principal_budget", errors);
1742        }
1743        if self.upstream_url.is_empty() {
1744            errors.push("upstream_url is required".into());
1745        }
1746        if !self.upstream_chat_path.starts_with('/') {
1747            errors.push(format!(
1748                "upstream_chat_path must start with '/', got {:?}",
1749                self.upstream_chat_path
1750            ));
1751        }
1752        if self.upstream_api_key_env.as_deref().is_some_and(str::is_empty) {
1753            errors.push("upstream_api_key_env must not be empty when set".into());
1754        }
1755        if self.upstream_api_key_file.as_deref().is_some_and(str::is_empty) {
1756            errors.push("upstream_api_key_file must not be empty when set".into());
1757        }
1758        if self.upstream_api_key_env.is_some() && self.upstream_api_key_file.is_some() {
1759            errors.push(
1760                "set only one of upstream_api_key_env or upstream_api_key_file (ambiguous key source)".into(),
1761            );
1762        }
1763        let has_static_key = self.upstream_api_key_env.is_some() || self.upstream_api_key_file.is_some();
1764        if self.upstream_authorization_passthrough && has_static_key {
1765            errors.push(
1766                "upstream_authorization_passthrough conflicts with upstream_api_key_env/file: choose one auth mode"
1767                    .into(),
1768            );
1769        }
1770        if self.upstream_authorization_passthrough && self.require_identity {
1771            errors.push(
1772                "upstream_authorization_passthrough cannot be combined with require_identity: the \
1773                 Authorization header carries the NHI token, which must never be sent upstream"
1774                    .into(),
1775            );
1776        }
1777        if self.ignore_client_authorization && self.require_identity {
1778            errors.push(
1779                "ignore_client_authorization cannot be combined with require_identity: the \
1780                 identity validator must see the Authorization header, not discard it"
1781                    .into(),
1782            );
1783        }
1784        if self.ignore_client_authorization && self.upstream_authorization_passthrough {
1785            errors.push(
1786                "ignore_client_authorization conflicts with upstream_authorization_passthrough: \
1787                 a client Authorization header cannot be both discarded and forwarded upstream"
1788                    .into(),
1789            );
1790        }
1791        if axum::http::HeaderName::try_from(self.upstream_auth_header.as_str()).is_err() {
1792            errors.push(format!(
1793                "upstream_auth_header {:?} is not a valid HTTP header name",
1794                self.upstream_auth_header
1795            ));
1796        }
1797        for entry in &self.allowed_hosts {
1798            // The Host check strips the request's :port before comparing,
1799            // so an entry carrying a port (`"localhost:8484"`), empty, or
1800            // padded with whitespace can NEVER match — every request 403s
1801            // and the operator learns it in production, not at boot.
1802            // (IPv6 literals are fine: the comparison strips brackets on
1803            // both sides; a port after the bracket is still refused.)
1804            let unbracketed = entry.trim_start_matches('[').trim_end_matches(']');
1805            let has_port = entry.rsplit_once(':').is_some_and(|(head, port)| {
1806                !head.is_empty()
1807                    && !port.is_empty()
1808                    && port.chars().all(|c| c.is_ascii_digit())
1809                    && !entry.ends_with(']')
1810            });
1811            if entry.trim().is_empty() {
1812                errors.push("allowed_hosts contains an empty entry".into());
1813            } else if entry != entry.trim() {
1814                errors.push(format!(
1815                    "allowed_hosts entry {entry:?} has leading/trailing whitespace and would never match a Host header"
1816                ));
1817            } else if has_port && unbracketed.parse::<std::net::Ipv6Addr>().is_err() {
1818                errors.push(format!(
1819                    "allowed_hosts entry {entry:?} includes a port; the Host check compares \
1820                     hostnames with the port stripped, so this entry would never match — drop the port"
1821                ));
1822            }
1823        }
1824        if self
1825            .upstream_auth_scheme
1826            .bytes()
1827            .any(|byte| !(0x21..=0x7e).contains(&byte))
1828        {
1829            errors.push(
1830                "upstream_auth_scheme must contain only visible ASCII with no spaces (use \"\" for a raw key)"
1831                    .into(),
1832            );
1833        }
1834        if self.tool_upstream_bearer_env.is_some() && self.tool_upstream_bearer_file.is_some() {
1835            errors.push(
1836                "set only one of tool_upstream_bearer_env or tool_upstream_bearer_file (ambiguous token source)"
1837                    .into(),
1838            );
1839        }
1840        // Parity with the
1841        // `upstream_api_key_env`/`_file` non-empty checks above.
1842        // The prior code refused ambiguity (both set) and refused
1843        // dangling (bearer without tool URL), but a caller supplying
1844        // an explicit empty string would fail only at first tool
1845        // call — at which point `resolve_upstream_bearer` returns
1846        // the same "environment variable {name} is not set (or
1847        // empty)" error the harness surfaces for a missing key.
1848        // Catch it here so the actionable message reaches the
1849        // operator at config load.
1850        if self
1851            .tool_upstream_bearer_env
1852            .as_deref()
1853            .is_some_and(str::is_empty)
1854        {
1855            errors.push("tool_upstream_bearer_env must not be empty when set".into());
1856        }
1857        if self
1858            .tool_upstream_bearer_file
1859            .as_deref()
1860            .is_some_and(str::is_empty)
1861        {
1862            errors.push("tool_upstream_bearer_file must not be empty when set".into());
1863        }
1864        if (self.tool_upstream_bearer_env.is_some() || self.tool_upstream_bearer_file.is_some())
1865            && self.tool_upstream_url.as_deref().is_none_or(str::is_empty)
1866        {
1867            errors.push("tool_upstream_bearer_env/file requires tool_upstream_url to be set".into());
1868        }
1869        if crate::session::Workflow::parse(&self.default_workflow).is_none() {
1870            errors.push(format!(
1871                "default_workflow must be signed|unsigned, got {:?}",
1872                self.default_workflow
1873            ));
1874        }
1875        // Refuse a principal-scoped budget while identity is optional. Without
1876        // require_identity=true, every unauthenticated request folds into a
1877        // single `"anonymous"` principal and the principal ledger becomes a
1878        // fleet-shared bucket that any caller can drain — the opposite of the
1879        // anti-rotation property the section is meant to provide. Operators
1880        // running a single-tenant appliance can flip
1881        // `allow_anonymous_principal_budget = true` to acknowledge the shape.
1882        if self.principal_budget.is_some() && !self.require_identity && !self.allow_anonymous_principal_budget
1883        {
1884            errors.push(
1885                "principal_budget was set but require_identity = false: every unauthenticated \
1886                 request would share a single `anonymous` principal ledger, making the budget \
1887                 a fleet-wide bucket instead of the per-principal cap it appears to be. Either \
1888                 set require_identity = true, or set allow_anonymous_principal_budget = true to \
1889                 acknowledge single-tenant / trusted-network semantics."
1890                    .into(),
1891            );
1892        }
1893        if self.require_identity
1894            && self.identity_jwks_url.as_deref().is_none_or(str::is_empty)
1895            && self
1896                .identity_hmac_secret_file
1897                .as_deref()
1898                .is_none_or(str::is_empty)
1899        {
1900            errors.push("require_identity=true needs identity_jwks_url or identity_hmac_secret_file".into());
1901        }
1902        // Reject the silent-anonymous-bypass posture.
1903        // `enforce_identity_scopes = true` looks like it's guarding
1904        // routes with `chat_scope` / `session_close_scope` / `tool:*`
1905        // — but the scope check lives INSIDE the `(Some bearer, Some
1906        // validator)` arm of `Pipeline::resolve_identity`. When
1907        // `require_identity = false` (the shipped default), a request
1908        // with no `Authorization` header short-circuits to the
1909        // anonymous fallback and never sees the scope gate. The
1910        // operator reads `enforce_identity_scopes = true` in
1911        // `proofvisor.toml` and reasonably concludes "you need the
1912        // chat scope to reach /v1/chat/completions" — in fact curl
1913        // with no header proceeds as `anonymous`, producing the
1914        // exact repudiation vector the Bearer-case fix
1915        // documented. Refuse the combo so operators either turn
1916        // enforcement off (making the posture explicit) or turn
1917        // identity on.
1918        if self.enforce_identity_scopes && !self.require_identity {
1919            errors.push(
1920                "enforce_identity_scopes=true has no effect while require_identity=false: \
1921                 unauthenticated requests fall through to the anonymous identity and bypass \
1922                 the scope gate entirely. Either set require_identity=true, or set \
1923                 enforce_identity_scopes=false to make the posture explicit."
1924                    .into(),
1925            );
1926        }
1927        if self.identity_jwks_refresh_s == 0 {
1928            errors.push("identity_jwks_refresh_s must be greater than zero".into());
1929        }
1930        // `tokio::time::interval(Duration::from_secs(0))` panics. Guard
1931        // against a value that squeaks past the > 0 check via overflow
1932        // arithmetic elsewhere by requiring a minimum plausible cadence
1933        // — a JWKS refresh below 30 s hammers the IdP and offers no
1934        // real benefit at NHI TTLs measured in minutes.
1935        if self.identity_jwks_refresh_s < 30 {
1936            errors.push(format!(
1937                "identity_jwks_refresh_s {} is too aggressive; a value below 30 s hammers the IdP \
1938                 without benefit given NHI TTLs measured in minutes",
1939                self.identity_jwks_refresh_s
1940            ));
1941        }
1942        if self.identity_hmac_kid.is_empty() {
1943            errors.push("identity_hmac_kid must not be empty".into());
1944        }
1945        // Scope names must be visible-ASCII non-empty
1946        // tokens. An empty `chat_scope = ""` under
1947        // `enforce_identity_scopes = true` + `require_identity =
1948        // true` used to silently reduce the check to
1949        // `identity.scopes.contains("")` — some IdPs emit empty
1950        // scope entries after tokenizing a stray whitespace claim,
1951        // so tokens without any real scope would satisfy the gate.
1952        // Also reject whitespace/control bytes (OAuth scope
1953        // tokens must not be re-tokenizable at any layer).
1954        for (field, value) in [
1955            ("chat_scope", &self.chat_scope),
1956            ("session_close_scope", &self.session_close_scope),
1957            ("session_promote_scope", &self.session_promote_scope),
1958        ] {
1959            if value.is_empty() {
1960                errors.push(format!("{field} must not be empty"));
1961            }
1962            if value.bytes().any(|byte| !(0x21..=0x7e).contains(&byte)) {
1963                errors.push(format!(
1964                    "{field} {value:?} must be visible ASCII with no whitespace or control bytes"
1965                ));
1966            }
1967        }
1968        if self.worker_channel_capacity == 0 {
1969            errors.push("worker_channel_capacity must be > 0".into());
1970        }
1971        // R70 F1 (landed R71): reject `mcp_concurrency == 0` (would
1972        // refuse every MCP call at admission) and values past
1973        // `MAX_MCP_CONCURRENCY` (would silently commit worst-case
1974        // 64 GiB+ of MCP buffering to the runtime).
1975        if self.mcp_concurrency == 0 {
1976            errors.push("mcp_concurrency must be > 0 (0 refuses every MCP call at admission)".into());
1977        }
1978        if self.mcp_concurrency > MAX_MCP_CONCURRENCY {
1979            errors.push(format!(
1980                "mcp_concurrency {} exceeds the safety cap of {} — each admitted MCP call \
1981                 can buffer up to 16 MiB, so oversized values silently commit multi-GB of \
1982                 resident memory to a slow-upstream attack surface",
1983                self.mcp_concurrency, MAX_MCP_CONCURRENCY
1984            ));
1985        }
1986        if crate::provider::adapter_for(&self.provider).is_none() {
1987            errors.push(format!(
1988                "provider must be one of {:?}, got {:?}",
1989                crate::provider::SUPPORTED_PROVIDERS,
1990                self.provider
1991            ));
1992        }
1993        if self
1994            .policy_bundle_file
1995            .as_deref()
1996            .is_some_and(|path| path.trim().is_empty())
1997        {
1998            errors.push("policy_bundle_file must not be empty when set".into());
1999        }
2000        if !(1..=3600).contains(&self.policy_bundle_poll_s) {
2001            errors.push("policy_bundle_poll_s must be between 1 and 3600".into());
2002        }
2003        if self.provider_default_max_tokens == 0 {
2004            errors.push(
2005                "provider_default_max_tokens must be at least 1 (Anthropic requires max_tokens)".into(),
2006            );
2007        }
2008        if self.bridge_manifest_path.is_empty() {
2009            errors.push("bridge_manifest_path is required".into());
2010        }
2011        // Refuse empty local-fs path fields. If an
2012        // operator overrides `atif_spool_dir = ""` in TOML (e.g. by
2013        // accidentally interpolating an unset env variable through a
2014        // template), every ATIF spool op used to run on Path::new("")
2015        // whose joins degrade to CWD-relative writes — receipts land
2016        // in the process CWD instead of the expected volume, recovery
2017        // scans miss them on the next boot. Same shape for
2018        // `bridge_data_dir` (embedded broker segments in CWD).
2019        if self.atif_spool_dir.is_empty() {
2020            errors.push("atif_spool_dir must not be empty".into());
2021        }
2022        if self.bridge_data_dir.is_empty() {
2023            errors.push("bridge_data_dir must not be empty".into());
2024        }
2025        // Refuse any payout_field that isn't visible ASCII. This closes:
2026        //   - empty and whitespace-only cases
2027        //   - leading/trailing whitespace
2028        //   - control characters
2029        //   - non-ASCII (accents, smart quotes)
2030        //   - AND the invisible-format class (BOM `\u{FEFF}`, zero-width
2031        //     space `\u{200B}`, U+2060 word-joiner, ...) that Rust's
2032        //     `trim()` misses because Unicode `White_Space` does not
2033        //     include them.
2034        // The invisible-format class is precisely what a browser or
2035        // rich-editor paste can inject into a config value, and
2036        // precisely the class where `serde_json::Value::get(field)`
2037        // silently misses the real JSON key — the same silent-bypass
2038        // class this field exists to prevent (see the field-level
2039        // doc-comment). Real tool schemas' payout field names are
2040        // always plain identifiers, so operator UX cost is zero.
2041        // `is_ascii_graphic()` is the visible-ASCII predicate
2042        // (0x21..=0x7E) shared with `SessionId::parse` and
2043        // `InstanceUid::parse`.
2044        if self.payout_field.is_empty() || !self.payout_field.chars().all(|c| c.is_ascii_graphic()) {
2045            errors.push(
2046                "payout_field must be non-empty visible ASCII (letters, digits, and \
2047                 common punctuation only). An empty, whitespace, or invisible-format \
2048                 (BOM / zero-width) value would match no argument key, silently disabling \
2049                 max_payout_usd_micros — the very silent-bypass class this field exists \
2050                 to prevent. Omit the key to use the default, or set it to your tool \
2051                 schema's payout argument name."
2052                    .into(),
2053            );
2054        }
2055        // Backend selectors: the typed accessors (`bridge()`, `state()`,
2056        // `embedder()`, `vector()`) are the single site owning the
2057        // legal-value vocabulary and the required-companion rules —
2058        // previously the four String
2059        // selectors were enumerated twice. The scheme
2060        // allowlists below run against the *resolved* companion, so
2061        // they can never fire on a missing value. The TOML wire
2062        // format is unchanged: shipped flat-key configs keep parsing.
2063        match self.bridge() {
2064            Err(error) => errors.push(error),
2065            Ok(BridgeBackend::Nats { endpoint }) => {
2066                if !(endpoint.starts_with("nats://") || endpoint.starts_with("tls://")) {
2067                    errors.push(format!(
2068                        "bridge_endpoint (nats backend) must be nats:// or tls://, got {endpoint:?}"
2069                    ));
2070                }
2071            }
2072            // Kafka bridge_endpoint is a `host:port[,host:port]` bootstrap
2073            // list, not a URL — no scheme check applies. rdkafka rejects
2074            // malformed values on connect.
2075            Ok(BridgeBackend::Embedded | BridgeBackend::Kafka { .. }) => {}
2076        }
2077        match self.state() {
2078            Err(error) => errors.push(error),
2079            Ok(StateBackend::Redis { endpoint }) => {
2080                // The state_endpoint
2081                // field is docstring-documented as a comma-separated
2082                // list of URLs for Redis Cluster mode (see the field
2083                // doc above). The scheme allowlist used to be a
2084                // prefix check on the WHOLE string, so
2085                // `redis://a:6379,http://b` passed validate (the
2086                // check saw the `redis://` prefix) and only failed
2087                // at connect. `redis+unix:` was also rejected here
2088                // even though it's a legitimate Unix-socket form the
2089                // redis crate accepts and the doctor's
2090                // `probe_endpoint_any` already recognizes. Split on
2091                // ',' and validate each member independently.
2092                let mut previous_tls = None;
2093                for (index, member) in endpoint.split(',').map(str::trim).enumerate() {
2094                    let ok = member.starts_with("redis://")
2095                        || member.starts_with("rediss://")
2096                        || member.starts_with("unix:")
2097                        || member.starts_with("redis+unix:");
2098                    if !ok {
2099                        errors.push(format!(
2100                            "state_endpoint (redis backend) member {} must be \
2101                             redis://, rediss://, unix:, or redis+unix:",
2102                            index + 1
2103                        ));
2104                    }
2105                    let tls = member.starts_with("rediss://");
2106                    if tls
2107                        && member
2108                            .split_once('#')
2109                            .is_some_and(|(_, fragment)| fragment == "insecure")
2110                    {
2111                        errors
2112                            .push("state_endpoint cannot disable Redis TLS certificate verification".into());
2113                    }
2114                    if previous_tls.is_some_and(|previous| previous != tls) {
2115                        errors.push("state_endpoint cluster members must all use the same transport".into());
2116                    }
2117                    previous_tls = Some(tls);
2118                }
2119            }
2120            Ok(StateBackend::Memory) => {}
2121        }
2122        if let Err(error) = self.embedder() {
2123            errors.push(error);
2124        }
2125        match self.vector() {
2126            Err(error) => errors.push(error),
2127            Ok(VectorBackend::Qdrant { url }) => {
2128                if !(url.starts_with("http://") || url.starts_with("https://")) {
2129                    errors.push(format!("qdrant_url must be http:// or https://, got {url:?}"));
2130                }
2131            }
2132            Ok(VectorBackend::Memory) => {}
2133        }
2134        if self.onnx_dimension == 0 {
2135            errors.push("onnx_dimension must be greater than zero".into());
2136        }
2137        if self.qdrant_collection.is_empty() {
2138            errors.push("qdrant_collection must not be empty".into());
2139        }
2140        if self.breaker.window == 0 {
2141            errors.push(
2142                "breaker.window must be greater than zero (0 trips on token count alone, ignoring semantic similarity)"
2143                    .into(),
2144            );
2145        }
2146        if !self.breaker.delta_epsilon.is_finite() || self.breaker.delta_epsilon <= 0.0 {
2147            errors.push(format!(
2148                "breaker.delta_epsilon must be a finite number greater than zero, got {}",
2149                self.breaker.delta_epsilon
2150            ));
2151        }
2152        // Upper bounds so a fat-finger in TOML cannot OOM the process
2153        // before the runtime feels the misconfiguration. Values are
2154        // deliberately loose: they only reject genuinely absurd numbers.
2155        if self.worker_channel_capacity > MAX_WORKER_CHANNEL_CAPACITY {
2156            errors.push(format!(
2157                "worker_channel_capacity {} exceeds the safety cap of {} — oversized channels hide real backpressure and let per-shard buffers grow unboundedly under overload",
2158                self.worker_channel_capacity, MAX_WORKER_CHANNEL_CAPACITY
2159            ));
2160        }
2161        if self.max_request_bytes == 0 {
2162            errors.push(
2163                "max_request_bytes must be > 0 — a 0 cap forwards to DefaultBodyLimit::max(0), \
2164                 rejecting every non-empty POST body silently"
2165                    .into(),
2166            );
2167        }
2168        if self.max_request_bytes > MAX_REQUEST_BYTES_CAP {
2169            errors.push(format!(
2170                "max_request_bytes {} exceeds the safety cap of {} (512 MiB) — a single request should never legitimately need more, and lifting this defeats the sandbox payload guard",
2171                self.max_request_bytes, MAX_REQUEST_BYTES_CAP
2172            ));
2173        }
2174        if self.onnx_dimension > MAX_ONNX_DIMENSION {
2175            errors.push(format!(
2176                "onnx_dimension {} exceeds the safety cap of {} — most sentence-transformer models are <= 4096",
2177                self.onnx_dimension, MAX_ONNX_DIMENSION
2178            ));
2179        }
2180        // 1 day is already unreasonably long for either an idle window
2181        // or a JWKS refresh cadence; anything larger is almost certainly
2182        // a unit-conversion error (someone thought the field was in ms).
2183        let mut interval_fields: Vec<(&'static str, u64)> = vec![
2184            ("reconcile_tick_s", self.reconcile_tick_s),
2185            ("session_idle_close_s", self.session_idle_close_s),
2186            ("identity_jwks_refresh_s", self.identity_jwks_refresh_s),
2187        ];
2188        if let Some(read_timeout) = self.upstream_read_timeout_s {
2189            // 0 passes the cap check below but makes reqwest's
2190            // read_timeout fire immediately — every upstream request
2191            // fails before the first byte arrives.
2192            if read_timeout == 0 {
2193                errors.push(
2194                    "upstream_read_timeout_s = 0 would time out every upstream read immediately — omit the key to use the built-in 60 s default".into(),
2195                );
2196            }
2197            interval_fields.push(("upstream_read_timeout_s", read_timeout));
2198        }
2199        if let Some(timeout) = self.mcp_request_timeout_s {
2200            if timeout == 0 {
2201                errors.push(
2202                    "mcp_request_timeout_s = 0 would time out every outbound MCP request immediately — omit the key to use the built-in 60 s default".into(),
2203                );
2204            }
2205            interval_fields.push(("mcp_request_timeout_s", timeout));
2206        }
2207        if let Some(drain) = self.shutdown_drain_timeout_s {
2208            if drain == 0 {
2209                errors.push(
2210                    "shutdown_drain_timeout_s = 0 would abandon every in-flight request at shutdown — omit the key to derive it from the upstream read-idle and MCP request timeouts".into(),
2211                );
2212            }
2213            interval_fields.push(("shutdown_drain_timeout_s", drain));
2214        }
2215        if self.shutdown_ready_drain_s > 0 {
2216            interval_fields.push(("shutdown_ready_drain_s", self.shutdown_ready_drain_s));
2217        }
2218        for (field, value) in interval_fields {
2219            if value > MAX_SECONDS_INTERVAL {
2220                errors.push(format!(
2221                    "{field} = {value} exceeds the safety cap of {MAX_SECONDS_INTERVAL} seconds (1 day) — did you mean milliseconds?"
2222                ));
2223            }
2224        }
2225        // Shape-only URL check: reject `upstream_url` that lacks a
2226        // scheme, so an operator setting e.g. `upstream_url =
2227        // "openai.internal"` (missing `https://`) does not silently
2228        // concatenate into a broken url. Full url::Url::parse is
2229        // deferred to reqwest at request time.
2230        //
2231        // Tighten to a strict http/https allowlist so
2232        // this matches the posture applied to
2233        // `identity_jwks_url`, `qdrant_url`, etc. The old
2234        // `contains("://")` check accepted `file:///etc/passwd`,
2235        // `gopher://…`, and other schemes even though the error text
2236        // claimed "must be http:// or https://" — a config-injection
2237        // primitive or a templating typo (`${UPSTREAM:-file:///…}`)
2238        // used to pass `pvctl config-validate` and only fail at
2239        // request time. Now every URL field's shape is preflighted
2240        // by the same rule.
2241        if !(self.upstream_url.starts_with("http://") || self.upstream_url.starts_with("https://")) {
2242            errors.push(format!(
2243                "upstream_url must be http:// or https://, got {:?}",
2244                self.upstream_url
2245            ));
2246        }
2247        // A scheme with no host (`http://`, `https:///path`) passed the
2248        // prefix check above, booted, and validated cleanly, failing
2249        // only at request time with a 502 — exactly the class this
2250        // preflight exists to catch at startup.
2251        {
2252            let rest = self
2253                .upstream_url
2254                .split_once("://")
2255                .map(|(_, rest)| rest)
2256                .unwrap_or_default();
2257            let host = rest.split(['/', '?', '#']).next().unwrap_or_default();
2258            if host.is_empty() {
2259                errors.push(format!("upstream_url has no host, got {:?}", self.upstream_url));
2260            }
2261        }
2262        // The chat/tool paths are CONCATENATED onto this base
2263        // (`format!("{upstream_url}/v1/chat/completions")` in
2264        // pipeline.rs), so a base carrying a query or fragment
2265        // silently swallows them: `http://host/#x` + `/v1/...` puts
2266        // the entire path inside the fragment and the actual request
2267        // targets `/` — validation passed, boot succeeded, and every
2268        // completion quietly hit the wrong endpoint. Refuse at
2269        // startup like every other config-shape problem.
2270        if self.upstream_url.contains(['?', '#']) {
2271            errors.push(format!(
2272                "upstream_url must not carry a query or fragment (request paths are \
2273                 appended to it), got {:?}",
2274                self.upstream_url
2275            ));
2276        }
2277        if let Some(tool_upstream) = &self.tool_upstream_url {
2278            // Empty is rejected rather than treated as unset: routing gates
2279            // tool forwarding on `is_some()` (routes.rs), so an empty string
2280            // would silently enable the tool-upstream branches and only fail
2281            // at the first request with a reqwest URL error. Fail loudly at
2282            // startup like every other config-shape problem.
2283            if tool_upstream.is_empty() {
2284                errors.push(
2285                    "tool_upstream_url must not be empty; omit the field to disable tool forwarding"
2286                        .to_owned(),
2287                );
2288            }
2289            if !(tool_upstream.starts_with("http://") || tool_upstream.starts_with("https://")) {
2290                errors.push(format!(
2291                    "tool_upstream_url must be http:// or https://, got {tool_upstream:?}"
2292                ));
2293            }
2294            // Same hostless-URL preflight as `upstream_url` above: a
2295            // scheme with no host (`http://`, `https:///mcp`) passed
2296            // validation, booted, then failed every tool call at
2297            // request-build time — a fail-late shape that also leaves
2298            // claimed executions churning TOOL_OUTCOME_UNCERTAIN.
2299            let rest = tool_upstream
2300                .split_once("://")
2301                .map(|(_, rest)| rest)
2302                .unwrap_or_default();
2303            let host = rest.split(['/', '?', '#']).next().unwrap_or_default();
2304            if !tool_upstream.is_empty() && host.is_empty() {
2305                errors.push(format!("tool_upstream_url has no host, got {tool_upstream:?}"));
2306            }
2307        }
2308        // Extend the scheme allowlist to every URL
2309        // field. `identity_jwks_url`, `qdrant_url`, `bridge_endpoint`
2310        // (when NATS), and `state_endpoint` (when Redis) all used to
2311        // be handed to their respective clients without preflight.
2312        // A typo like `identity_jwks_url = "auth.internal/jwks"`
2313        // (missing scheme) would pass `pvctl config-validate` and
2314        // only fail at first fetch — after the process was already
2315        // serving traffic with an empty validator (every token ->
2316        // UnknownKid). A hostile config-injection
2317        // `identity_jwks_url = "file:///etc/passwd"` used to be
2318        // just as invisible. Reject shape early.
2319        if let Some(url) = self
2320            .identity_jwks_url
2321            .as_deref()
2322            .filter(|value| !value.is_empty())
2323        {
2324            if !(url.starts_with("http://") || url.starts_with("https://")) {
2325                errors.push(format!(
2326                    "identity_jwks_url must be http:// or https://, got {url:?}"
2327                ));
2328            }
2329        }
2330        // The qdrant_url / state_endpoint / bridge_endpoint scheme
2331        // allowlists live in the typed-backend block above, running
2332        // against the resolved companion values.
2333
2334        // ── Pillar 2: intent mapping / AuthZEN ──────────────────────
2335        if self.require_intent_mapping && self.intent_map.is_empty() {
2336            errors.push(
2337                "require_intent_mapping is true but intent_map is empty; every tool call \
2338                 would be denied with UNMAPPED_TOOL"
2339                    .into(),
2340            );
2341        }
2342        if self.intent_token_ttl_s == 0 {
2343            errors.push("intent_token_ttl_s must be > 0".into());
2344        }
2345        if self.intent_token_ttl_s > 900 {
2346            errors.push(
2347                "intent_token_ttl_s exceeds the 900 s ceiling; intent tokens must be short-lived".into(),
2348            );
2349        }
2350        let mut mission_ids = std::collections::HashSet::new();
2351        for mission in self.mission.iter().chain(&self.missions) {
2352            if !mission_ids.insert(mission.id.as_str()) {
2353                errors.push(format!("mission id {:?} is used more than once", mission.id));
2354            }
2355        }
2356        if self.mission.as_ref().is_some_and(MissionConfig::has_selectors) {
2357            errors.push(
2358                "[mission] applies to every caller and takes no agents/charters/subjects; \
2359                 put per-agent missions in [[missions]]"
2360                    .into(),
2361            );
2362        }
2363        for mission in &self.missions {
2364            if !mission.has_selectors() {
2365                errors.push(format!(
2366                    "[[missions]] entry {:?} has no agents, charters, or subjects; use [mission] \
2367                     for a mission that applies to every caller",
2368                    mission.id
2369                ));
2370            }
2371            if mission.allowed_intents.is_empty() {
2372                errors.push(format!(
2373                    "mission {:?} has no allowed_intents; every call would be denied",
2374                    mission.id
2375                ));
2376            }
2377        }
2378        if let Some(workload) = &self.workload_identity {
2379            if workload.ca_file.trim().is_empty() {
2380                errors.push("workload_identity.ca_file must not be empty".into());
2381            }
2382            if !(60..=900).contains(&workload.token_ttl_s) {
2383                errors.push("workload_identity.token_ttl_s must be between 60 and 900".into());
2384            }
2385            if self.token_exchange_seed_file.is_none() {
2386                errors.push(
2387                    "[workload_identity] requires token_exchange_seed_file: the gateway signs agent \
2388                     tokens with that key"
2389                        .into(),
2390                );
2391            }
2392            if self.workload_identities.is_empty() {
2393                errors
2394                    .push("[workload_identity] is set but no [[workload_identities]] are registered".into());
2395            }
2396        } else if !self.workload_identities.is_empty() {
2397            errors.push("[[workload_identities]] requires [workload_identity]".into());
2398        }
2399        // The same rule the identity validator applies at request time.
2400        let human = self
2401            .identity_human_subject_pattern
2402            .as_deref()
2403            .and_then(|pattern| {
2404                let mut validator = pv_identity::IdentityValidator::new(&self.audience);
2405                validator
2406                    .set_human_subject_pattern(pattern)
2407                    .ok()
2408                    .map(|()| validator)
2409            });
2410        let mut workload_names = std::collections::HashSet::new();
2411        for workload in &self.workload_identities {
2412            if !workload_names.insert(workload.name.as_str()) {
2413                errors.push(format!(
2414                    "workload name {:?} is used more than once",
2415                    workload.name
2416                ));
2417            }
2418            if workload.name.is_empty()
2419                || !workload
2420                    .name
2421                    .bytes()
2422                    .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
2423            {
2424                errors.push(format!(
2425                    "workload name {:?} must be ASCII letters, digits, '-', '_', or '.'",
2426                    workload.name
2427                ));
2428            }
2429            if workload.cf_app_guid.is_none()
2430                && workload.cf_space_guid.is_none()
2431                && workload.cf_org_guid.is_none()
2432            {
2433                errors.push(format!(
2434                    "workload {:?} needs cf_app_guid, cf_space_guid, or cf_org_guid",
2435                    workload.name
2436                ));
2437            }
2438            if workload.sub.trim().is_empty() {
2439                errors.push(format!(
2440                    "workload {:?} needs `sub`: the human principal its agents act for",
2441                    workload.name
2442                ));
2443            } else if human
2444                .as_ref()
2445                .is_some_and(|validator| !validator.subject_is_human(&workload.sub))
2446            {
2447                errors.push(format!(
2448                    "workload {:?} sub {:?} does not match identity_human_subject_pattern",
2449                    workload.name, workload.sub
2450                ));
2451            }
2452            if workload.charter.trim().is_empty() || workload.scopes.is_empty() {
2453                errors.push(format!(
2454                    "workload {:?} needs a charter and at least one scope",
2455                    workload.name
2456                ));
2457            }
2458        }
2459        if let Some(authzen) = &self.authzen {
2460            for (field, endpoint) in
2461                std::iter::once(("authzen.evaluation_endpoint", Some(&authzen.evaluation_endpoint))).chain(
2462                    std::iter::once((
2463                        "authzen.evaluations_endpoint",
2464                        authzen.evaluations_endpoint.as_ref(),
2465                    )),
2466                )
2467            {
2468                if let Some(endpoint) = endpoint {
2469                    if let Err(error) = crate::backend::validate_http_endpoint(endpoint, field) {
2470                        errors.push(error);
2471                    }
2472                }
2473            }
2474            if !(1..=10_000).contains(&authzen.timeout_ms) {
2475                errors.push("authzen.timeout_ms must be between 1 and 10000".into());
2476            }
2477            if authzen
2478                .auth_file
2479                .as_deref()
2480                .is_some_and(|path| path.trim().is_empty())
2481            {
2482                errors.push("authzen.auth_file must not be empty".into());
2483            }
2484        }
2485        if let Some(ref mission) = self.mission {
2486            let now_s = pv_core::time::now_ms() / 1000;
2487            if mission.expires_at <= now_s {
2488                tracing::warn!(
2489                    mission_id = %mission.id,
2490                    expires_at = mission.expires_at,
2491                    now = now_s,
2492                    "mission is already expired; the PDP will deny every call \
2493                     until the mission is removed or updated"
2494                );
2495            }
2496            if mission.allowed_intents.is_empty() {
2497                errors.push(format!(
2498                    "mission {:?} has no allowed_intents; every call would be denied",
2499                    mission.id
2500                ));
2501            }
2502        }
2503
2504        // ── Pillar 3: token exchange ────────────────────────────────
2505        if self
2506            .token_exchange_seed_file
2507            .as_ref()
2508            .is_some_and(|path| path.trim().is_empty())
2509        {
2510            errors.push(
2511                "token_exchange_seed_file must not be empty; omit it when token signing is disabled".into(),
2512            );
2513        }
2514        if self.token_exchange_enabled && self.token_exchange_seed_file.is_none() {
2515            errors.push(
2516                "token_exchange_enabled is true but token_exchange_seed_file is not set; \
2517                 the /v1/token endpoint has no key to sign exchanged tokens with"
2518                    .into(),
2519            );
2520        }
2521        let has_identity_source = self
2522            .identity_jwks_url
2523            .as_deref()
2524            .is_some_and(|url| !url.is_empty())
2525            || self
2526                .identity_hmac_secret_file
2527                .as_deref()
2528                .is_some_and(|path| !path.is_empty());
2529        if self.token_exchange_enabled && !has_identity_source {
2530            errors.push(
2531                "token_exchange_enabled is true but neither identity_jwks_url nor \
2532                 identity_hmac_secret_file is set; the /v1/token endpoint cannot validate \
2533                 subject tokens"
2534                    .into(),
2535            );
2536        }
2537        if self.token_exchange_enabled && self.backends.is_empty() {
2538            errors.push(
2539                "token_exchange_enabled is true but no [[backends]] are configured; the \
2540                 gateway signs exchanged tokens only for configured backend names, so every \
2541                 exchange would be refused with invalid_target"
2542                    .into(),
2543            );
2544        }
2545        if self.token_exchange_ttl_s == 0 {
2546            errors.push("token_exchange_ttl_s must be > 0".into());
2547        }
2548        if self.token_exchange_ttl_s > 900 {
2549            errors.push(format!(
2550                "token_exchange_ttl_s = {} exceeds the 900 s ceiling; \
2551                 exchanged tokens must be short-lived",
2552                self.token_exchange_ttl_s
2553            ));
2554        }
2555        let has_exchange_backend = self
2556            .backends
2557            .iter()
2558            .any(|b| matches!(b.auth, BackendAuth::Exchange));
2559        if has_exchange_backend && self.token_exchange_seed_file.is_none() {
2560            errors.push(
2561                "a backend uses auth = \"exchange\" but token_exchange_seed_file is not set; \
2562                 the gateway cannot sign exchanged tokens without a seed"
2563                    .into(),
2564            );
2565        }
2566        if has_exchange_backend && !self.require_identity {
2567            errors.push(
2568                "a backend uses auth = \"exchange\" but require_identity is false; \
2569                 the gateway needs a validated caller identity to perform token exchange"
2570                    .into(),
2571            );
2572        }
2573        if self
2574            .backends
2575            .iter()
2576            .any(|b| matches!(b.auth, BackendAuth::ForwardToken))
2577            && !self.require_identity
2578        {
2579            errors.push(
2580                "a backend uses auth = \"forward_token\" but require_identity is false; \
2581                 only a validated caller token may be forwarded to a backend"
2582                    .into(),
2583            );
2584        }
2585        if let Some(pattern) = &self.identity_human_subject_pattern {
2586            if let Err(error) =
2587                pv_identity::IdentityValidator::new(&self.audience).set_human_subject_pattern(pattern)
2588            {
2589                errors.push(format!("identity_human_subject_pattern: {error}"));
2590            }
2591        }
2592        for origin in &self.mcp_allowed_origins {
2593            if let Err(error) = validate_origin(origin) {
2594                errors.push(format!("mcp_allowed_origins entry {origin:?}: {error}"));
2595            }
2596        }
2597        errors.extend(crate::backend::validate_configs(&self.backends));
2598        if self.require_private_backends {
2599            for (field, url) in self
2600                .backends
2601                .iter()
2602                .map(|backend| (format!("backend {:?} url", backend.name), backend.url.as_str()))
2603                .chain(
2604                    self.tool_upstream_url
2605                        .iter()
2606                        .map(|url| ("tool_upstream_url".to_owned(), url.as_str())),
2607                )
2608            {
2609                if let Err(error) = crate::backend::validate_private_endpoint(url, &field) {
2610                    errors.push(error);
2611                }
2612            }
2613        }
2614        if self.backends.iter().any(|backend| backend.name == self.audience) {
2615            errors.push(format!(
2616                "a backend is named {:?}, the same as `audience`; tokens this gateway signs for that \
2617                 backend would also be valid inbound identities, so choose another name",
2618                self.audience
2619            ));
2620        }
2621        if !self.redaction_patterns.is_empty() || !self.redaction_paths.is_empty() {
2622            if let Err(error) = pv_redact::RedactionEngine::new(pv_redact::RedactionConfig {
2623                regex_patterns: self.redaction_patterns.clone(),
2624                pointer_paths: self.redaction_paths.clone(),
2625                ..pv_redact::RedactionConfig::default()
2626            }) {
2627                errors.push(format!("invalid redaction configuration: {error}"));
2628            }
2629        }
2630        if let Some(endpoint) = &self.otel_tenant_endpoint {
2631            if let Err(error) = crate::backend::validate_http_endpoint(endpoint, "otel_tenant_endpoint") {
2632                errors.push(error);
2633            }
2634        }
2635        if let Some(path) = &self.otel_tenant_auth_file {
2636            if path.trim().is_empty() {
2637                errors.push("otel_tenant_auth_file must not be empty".into());
2638            }
2639            if self.otel_tenant_endpoint.is_none() {
2640                errors.push("otel_tenant_auth_file requires otel_tenant_endpoint".into());
2641            }
2642        }
2643        if self.revocation_audit_per_minute == 0 {
2644            errors.push("revocation_audit_per_minute must be > 0".into());
2645        }
2646        let valid_digest = |digest: &str| digest.len() == 64 && digest.bytes().all(|b| b.is_ascii_hexdigit());
2647        let mut operator_names = std::collections::HashSet::new();
2648        let mut introspection_backends = std::collections::HashSet::new();
2649        let mut credential_digests = std::collections::HashSet::new();
2650        if !self.operator_tokens.is_empty() && !has_identity_source {
2651            errors.push("operator_tokens requires identity_jwks_url or identity_hmac_secret_file".into());
2652        }
2653        if !self.introspection_tokens.is_empty() {
2654            if !has_identity_source {
2655                errors.push(
2656                    "introspection_tokens requires identity_jwks_url or identity_hmac_secret_file".into(),
2657                );
2658            }
2659            if self
2660                .token_exchange_seed_file
2661                .as_deref()
2662                .is_none_or(|path| path.trim().is_empty())
2663            {
2664                errors.push("introspection_tokens requires token_exchange_seed_file".into());
2665            }
2666        }
2667        for token in &self.operator_tokens {
2668            if token.name.trim().is_empty() || !operator_names.insert(&token.name) {
2669                errors.push("operator_tokens names must be non-empty and unique".into());
2670            }
2671            if !valid_digest(&token.sha256) {
2672                errors.push(format!(
2673                    "operator_tokens {:?} sha256 must contain exactly 64 hexadecimal characters",
2674                    token.name
2675                ));
2676            }
2677            if !credential_digests.insert(token.sha256.to_ascii_lowercase()) {
2678                errors.push(
2679                    "operator_tokens and introspection_tokens must use distinct sha256 credential digests"
2680                        .into(),
2681                );
2682            }
2683        }
2684        for token in &self.introspection_tokens {
2685            if !introspection_backends.insert(&token.backend) {
2686                errors.push(format!(
2687                    "duplicate introspection_tokens backend {:?}",
2688                    token.backend
2689                ));
2690            }
2691            let known_backend = self.backends.iter().any(|backend| backend.name == token.backend)
2692                || (token.backend == crate::backend::DEFAULT_BACKEND_NAME
2693                    && self.backends.is_empty()
2694                    && self.tool_upstream_url.is_some());
2695            if !known_backend {
2696                errors.push(format!(
2697                    "introspection_tokens references unknown backend {:?}",
2698                    token.backend
2699                ));
2700            }
2701            if !valid_digest(&token.sha256) {
2702                errors.push(format!(
2703                    "introspection_tokens {:?} sha256 must contain exactly 64 hexadecimal characters",
2704                    token.backend
2705                ));
2706            }
2707            if !credential_digests.insert(token.sha256.to_ascii_lowercase()) {
2708                errors.push(
2709                    "operator_tokens and introspection_tokens must use distinct sha256 credential digests"
2710                        .into(),
2711                );
2712            }
2713        }
2714    }
2715
2716    /// R69 F3 (landed R70): reject a `BudgetSpec` with any `Some(0)`
2717    /// numeric cap or a zero entry in `max_tool_calls`. See the
2718    /// call-site comment in `collect_validation_errors` for
2719    /// rationale; message shape matches the other `must be > 0`
2720    /// errors elsewhere in that function.
2721    fn validate_budget_spec(spec: &pv_state::BudgetSpec, scope: &str, errors: &mut Vec<String>) {
2722        if spec.max_tokens == Some(0) {
2723            errors.push(format!(
2724                "{scope}.max_tokens must be > 0 (Some(0) refuses every non-zero spend, 429-storming \
2725                 every non-empty chat request); omit the field to disable the cap"
2726            ));
2727        }
2728        if spec.max_total_tool_calls == Some(0) {
2729            errors.push(format!(
2730                "{scope}.max_total_tool_calls must be > 0 (Some(0) refuses every tool call); \
2731                 omit the field to disable the cap"
2732            ));
2733        }
2734        if spec.max_payout_usd_micros == Some(0) {
2735            errors.push(format!(
2736                "{scope}.max_payout_usd_micros must be > 0 (Some(0) refuses every non-zero payout); \
2737                 omit the field to disable the cap"
2738            ));
2739        }
2740        for (tool, cap) in &spec.max_tool_calls {
2741            if *cap == 0 {
2742                errors.push(format!(
2743                    "{scope}.max_tool_calls[{tool:?}] must be > 0 (0 silently disables the tool with \
2744                     no error surface); omit the entry to leave the tool uncapped, or set >= 1"
2745                ));
2746            }
2747        }
2748    }
2749}
2750
2751#[cfg(test)]
2752mod tests {
2753    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
2754
2755    #[test]
2756    fn security_config_rejects_backend_routing_and_redaction_errors_structurally() {
2757        let base = HarnessConfig::for_tests("http://upstream", "/tmp/spool", "/tmp/bridge");
2758        base.validate().unwrap();
2759        for (name, url, tools, expected) in [
2760            ("", "http://backend", vec!["read"], "backend name"),
2761            ("default", "http://backend", vec!["read"], "reserved"),
2762            ("tools", "https:///mcp", vec!["read"], "host"),
2763            ("tools", "file:///tmp/mcp", vec!["read"], "http"),
2764            ("tools", "http://backend:bad", vec!["read"], "valid URL"),
2765            ("tools", "http://backend", vec!["Read"], "lowercase ASCII"),
2766            ("tools", "http://backend", vec!["read", "read"], "repeated"),
2767        ] {
2768            let mut config = base.clone();
2769            config.backends = vec![BackendConfig {
2770                name: name.into(),
2771                url: url.into(),
2772                auth: BackendAuth::None,
2773                tools: tools.into_iter().map(str::to_owned).collect(),
2774                transport: Default::default(),
2775            }];
2776            assert!(config.validate().unwrap_err().contains(expected));
2777        }
2778        for (patterns, paths) in [
2779            (vec!["["], vec![]),
2780            (vec!["REDACTED"], vec![]),
2781            (vec![], vec!["password"]),
2782        ] {
2783            let mut config = base.clone();
2784            config.redaction_patterns = patterns.into_iter().map(str::to_owned).collect();
2785            config.redaction_paths = paths.into_iter().map(str::to_owned).collect();
2786            assert!(config.validate().unwrap_err().contains("redaction"));
2787        }
2788        for (ttl, expected) in [(0, false), (900, true), (901, false), (u64::MAX, false)] {
2789            let mut config = base.clone();
2790            config.intent_token_ttl_s = ttl;
2791            assert_eq!(config.validate().is_ok(), expected);
2792        }
2793        let mut empty_seed = base;
2794        empty_seed.token_exchange_seed_file = Some(String::new());
2795        assert!(empty_seed
2796            .validate()
2797            .unwrap_err()
2798            .contains("token_exchange_seed_file"));
2799    }
2800
2801    #[test]
2802    fn security_config_separates_structural_otel_checks_from_build_features() {
2803        let mut config = HarnessConfig::for_tests("http://upstream", "/tmp/spool", "/tmp/bridge");
2804        config.otel_tenant_endpoint = Some("https://tenant.example/v1/traces".into());
2805        config.validate().unwrap();
2806        assert_eq!(
2807            config
2808                .unsupported_backend_requirements()
2809                .iter()
2810                .any(|error| error.contains("otel")),
2811            !cfg!(feature = "otel")
2812        );
2813        config.otel_tenant_endpoint = Some("tenant.example/traces".into());
2814        assert!(config.validate().unwrap_err().contains("otel_tenant_endpoint"));
2815        config.otel_tenant_endpoint = None;
2816        config.otel_tenant_auth_file = Some("/run/secrets/tenant".into());
2817        assert!(config
2818            .validate()
2819            .unwrap_err()
2820            .contains("requires otel_tenant_endpoint"));
2821    }
2822
2823    #[test]
2824    fn security_config_validates_operator_and_introspection_credentials() {
2825        let mut config = HarnessConfig::for_tests("http://upstream", "/tmp/spool", "/tmp/bridge");
2826        config.identity_hmac_secret_file = Some("/run/secrets/identity".into());
2827        config.token_exchange_seed_file = Some("/run/secrets/exchange".into());
2828        config.backends = vec![BackendConfig {
2829            name: "db".into(),
2830            url: "http://db".into(),
2831            auth: BackendAuth::None,
2832            tools: vec![],
2833            transport: Default::default(),
2834        }];
2835        config.operator_tokens = vec![OperatorTokenConfig {
2836            name: "operator".into(),
2837            sha256: "ab".repeat(32),
2838        }];
2839        config.introspection_tokens = vec![IntrospectionTokenConfig {
2840            backend: "db".into(),
2841            sha256: "cd".repeat(32),
2842        }];
2843        config.validate().unwrap();
2844        let good = config.clone();
2845        config
2846            .operator_tokens
2847            .push(config.operator_tokens.first().unwrap().clone());
2848        assert!(config.validate().unwrap_err().contains("unique"));
2849        config = good.clone();
2850        config
2851            .introspection_tokens
2852            .push(config.introspection_tokens.first().unwrap().clone());
2853        assert!(config
2854            .validate()
2855            .unwrap_err()
2856            .contains("duplicate introspection_tokens"));
2857        config = good.clone();
2858        config.introspection_tokens.first_mut().unwrap().backend = "unknown".into();
2859        assert!(config.validate().unwrap_err().contains("unknown backend"));
2860        for digest in ["f".repeat(63), "g".repeat(64), "f".repeat(65)] {
2861            config = good.clone();
2862            config.operator_tokens.first_mut().unwrap().sha256 = digest.clone();
2863            config.introspection_tokens.first_mut().unwrap().sha256 = digest;
2864            let errors = config.validate().unwrap_err();
2865            assert!(errors.contains("operator_tokens"));
2866            assert!(errors.contains("introspection_tokens"));
2867        }
2868        config = good;
2869        config.revocation_audit_per_minute = 0;
2870        assert!(config
2871            .validate()
2872            .unwrap_err()
2873            .contains("revocation_audit_per_minute"));
2874    }
2875
2876    #[test]
2877    fn security_config_rejects_reused_control_credentials_and_missing_identity_sources() {
2878        let mut config = HarnessConfig::for_tests("http://upstream", "/tmp/spool", "/tmp/bridge");
2879        config.identity_hmac_secret_file = Some("/run/secrets/identity".into());
2880        config.token_exchange_seed_file = Some("/run/secrets/exchange".into());
2881        config.backends = ["db", "search"]
2882            .into_iter()
2883            .map(|name| BackendConfig {
2884                name: name.into(),
2885                url: format!("http://{name}"),
2886                auth: BackendAuth::None,
2887                tools: vec![name.into()],
2888                transport: Default::default(),
2889            })
2890            .collect();
2891        config.operator_tokens = vec![OperatorTokenConfig {
2892            name: "operator".into(),
2893            sha256: "ab".repeat(32),
2894        }];
2895        config.introspection_tokens = vec![IntrospectionTokenConfig {
2896            backend: "db".into(),
2897            sha256: "cd".repeat(32),
2898        }];
2899        config.validate().unwrap();
2900        let valid = config.clone();
2901
2902        config.operator_tokens.push(OperatorTokenConfig {
2903            name: "second-operator".into(),
2904            sha256: "AB".repeat(32),
2905        });
2906        assert!(config.validate().unwrap_err().contains("distinct sha256"));
2907        config = valid.clone();
2908        config.introspection_tokens.push(IntrospectionTokenConfig {
2909            backend: "search".into(),
2910            sha256: "CD".repeat(32),
2911        });
2912        assert!(config.validate().unwrap_err().contains("distinct sha256"));
2913        config = valid.clone();
2914        config.introspection_tokens.first_mut().unwrap().sha256 = config
2915            .operator_tokens
2916            .first()
2917            .unwrap()
2918            .sha256
2919            .to_ascii_uppercase();
2920        assert!(config.validate().unwrap_err().contains("distinct sha256"));
2921
2922        config = valid.clone();
2923        config.identity_hmac_secret_file = None;
2924        let errors = config.validate().unwrap_err();
2925        assert!(errors.contains("operator_tokens requires identity"));
2926        assert!(errors.contains("introspection_tokens requires identity"));
2927        config = valid;
2928        config.token_exchange_seed_file = None;
2929        assert!(config
2930            .validate()
2931            .unwrap_err()
2932            .contains("introspection_tokens requires token_exchange_seed_file"));
2933        config.introspection_tokens.clear();
2934        config.validate().unwrap();
2935    }
2936
2937    #[cfg(unix)]
2938    #[test]
2939    fn security_config_loads_tenant_auth_with_owner_only_permissions() {
2940        use std::os::unix::fs::PermissionsExt;
2941        let directory = tempfile::tempdir().unwrap();
2942        let path = directory.path().join("tenant-token");
2943        std::fs::write(&path, "tenant-secret\n").unwrap();
2944        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
2945        let mut config = HarnessConfig::for_tests("http://upstream", "/tmp/spool", "/tmp/bridge");
2946        config.otel_tenant_auth_file = Some(path.to_string_lossy().into_owned());
2947        let header = config.tenant_otel_authorization().unwrap().unwrap();
2948        assert_eq!(header, "Bearer tenant-secret");
2949        assert!(header.is_sensitive());
2950        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2951        assert!(config.tenant_otel_authorization().is_err());
2952    }
2953
2954    /// Register item 25: docs/reference/CONFIGURATION.md claims to be
2955    /// the configuration reference, yet 29 of 63 fields (including
2956    /// `default_workflow` and `ignore_client_authorization` — the two
2957    /// knobs at the center of the onboarding story) shipped
2958    /// undocumented. Every `HarnessConfig` field must appear in the
2959    /// reference; adding a config key without documenting it now
2960    /// fails CI by design.
2961    #[test]
2962    fn configuration_reference_documents_every_config_field() {
2963        let source = include_str!("config.rs");
2964        let reference = include_str!("../../../docs/reference/CONFIGURATION.md");
2965        let body = source
2966            .split_once("pub struct HarnessConfig {")
2967            .map(|(_, rest)| rest)
2968            .and_then(|rest| rest.split_once("\n}"))
2969            .map(|(body, _)| body)
2970            .unwrap();
2971        let fields: Vec<&str> = body
2972            .lines()
2973            .filter_map(|line| line.trim().strip_prefix("pub "))
2974            .filter_map(|rest| rest.split_once(':'))
2975            .map(|(name, _)| name.trim())
2976            .collect();
2977        assert!(
2978            fields.len() >= 60,
2979            "field extraction broke — found only {} fields",
2980            fields.len()
2981        );
2982        for field in fields {
2983            assert!(
2984                reference.contains(&format!("`{field}`")) || reference.contains(&format!("`[{field}]`")),
2985                "config field `{field}` is not documented in docs/reference/CONFIGURATION.md"
2986            );
2987        }
2988    }
2989
2990    use super::*;
2991
2992    #[test]
2993    fn minimal_toml_parses_with_defaults() {
2994        let cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
2995        assert_eq!(cfg.config_version, CONFIG_VERSION);
2996        assert_eq!(cfg.default_workflow, "unsigned");
2997        assert_eq!(cfg.session_idle_close_s, 900);
2998        assert!(cfg.compression_enabled);
2999        assert_eq!(cfg.upstream_chat_path, "/v1/chat/completions");
3000        assert_eq!(cfg.upstream_auth_header, "authorization");
3001        assert_eq!(cfg.upstream_auth_scheme, "Bearer");
3002        assert!(cfg.upstream_api_key_env.is_none());
3003        assert!(!cfg.upstream_authorization_passthrough);
3004    }
3005
3006    /// Validation must surface EVERY violation in one
3007    /// run, not one per `pvctl config-validate` round-trip.
3008    /// `allowed_hosts` entries that can never match the port-stripped
3009    /// request Host (ports, whitespace, empties) must be refused at
3010    /// boot — the alternative is every request 403ing in production
3011    /// with no hint that the config entry shape is wrong.
3012    #[test]
3013    fn validate_refuses_unmatchable_allowed_hosts_entries() {
3014        for (entry, needle) in [
3015            ("localhost:8484", "includes a port"),
3016            ("[::1]:8484", "includes a port"),
3017            (" localhost", "whitespace"),
3018            ("", "empty"),
3019        ] {
3020            let mut cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
3021            cfg.allowed_hosts = vec![entry.to_owned()];
3022            let error = cfg.validate().unwrap_err();
3023            assert!(
3024                error.contains(needle),
3025                "entry {entry:?}: expected {needle:?} in: {error}"
3026            );
3027        }
3028        // Legitimate shapes still pass: hostname, IPv4, bare and
3029        // bracketed IPv6 (bare IPv6 has colons but is not host:port).
3030        for entry in [
3031            "localhost",
3032            "127.0.0.1",
3033            "::1",
3034            "2001:db8::1",
3035            "[::1]",
3036            "proofvisor.internal",
3037        ] {
3038            let mut cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
3039            cfg.allowed_hosts = vec![entry.to_owned()];
3040            cfg.validate()
3041                .unwrap_or_else(|e| panic!("entry {entry:?} must pass: {e}"));
3042        }
3043    }
3044
3045    #[test]
3046    fn validate_reports_all_errors_at_once() {
3047        let mut cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
3048        cfg.listen = String::new();
3049        cfg.bridge_backend = "carrier-pigeon".to_owned();
3050        cfg.state_backend = "abacus".to_owned();
3051        let error = cfg.validate().unwrap_err();
3052        assert!(
3053            error.contains("config errors"),
3054            "aggregate header missing: {error}"
3055        );
3056        for needle in ["listen", "bridge_backend", "state_backend"] {
3057            assert!(error.contains(needle), "missing {needle} in: {error}");
3058        }
3059    }
3060
3061    /// A single violation keeps the historical bare-message shape
3062    /// (no aggregate header) so existing tooling that string-matches
3063    /// one error keeps working.
3064    #[test]
3065    fn validate_single_error_keeps_bare_message() {
3066        let mut cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
3067        cfg.state_backend = "abacus".to_owned();
3068        let error = cfg.validate().unwrap_err();
3069        assert!(
3070            !error.contains("config errors"),
3071            "single error must not aggregate: {error}"
3072        );
3073        assert!(error.contains("state_backend"), "must name the field: {error}");
3074    }
3075
3076    /// An unsupported provider dialect fails
3077    /// pre-flight, naming the supported set — never mid-stream.
3078    #[test]
3079    fn unsupported_provider_is_refused_at_validation() {
3080        let mut cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
3081        assert_eq!(cfg.provider, "openai");
3082        cfg.provider = "google".to_owned();
3083        let error = cfg.validate().unwrap_err();
3084        assert!(error.contains("provider"), "must name the field: {error}");
3085        assert!(error.contains("openai"), "must name the supported set: {error}");
3086        cfg.provider = "anthropic".to_owned();
3087        assert!(
3088            cfg.validate().is_ok(),
3089            "anthropic is a supported dialect (S3 step 2)"
3090        );
3091    }
3092
3093    /// The default backends (embedded/memory/hash/
3094    /// memory) require no cargo feature and must always be runnable.
3095    #[test]
3096    fn default_backends_never_report_unsupported_requirements() {
3097        let cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
3098        assert!(cfg.unsupported_backend_requirements().is_empty());
3099    }
3100
3101    /// Each feature-gated backend is reported exactly
3102    /// when its cargo feature is compiled out — written against
3103    /// `cfg!` so the same assertion holds under default features AND
3104    /// `--all-features` CI runs. Companions are set because feature
3105    /// detection now runs over the typed backends: a selection that
3106    /// cannot resolve is `validate()`'s problem, not this method's.
3107    #[test]
3108    fn unsupported_backend_requirements_track_build_features() {
3109        let mut cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
3110        cfg.bridge_backend = "kafka".to_owned();
3111        cfg.bridge_endpoint = Some("broker:9092".to_owned());
3112        cfg.state_backend = "redis".to_owned();
3113        cfg.state_endpoint = Some("redis://cache:6379".to_owned());
3114        cfg.embedder_backend = "onnx".to_owned();
3115        cfg.onnx_model_path = Some("model.onnx".to_owned());
3116        cfg.onnx_tokenizer_path = Some("tokenizer.json".to_owned());
3117        cfg.vector_backend = "qdrant".to_owned();
3118        cfg.qdrant_url = Some("http://vectors:6333".to_owned());
3119        let missing = cfg.unsupported_backend_requirements();
3120        for (enabled, feature) in [
3121            (cfg!(feature = "kafka"), "kafka"),
3122            (cfg!(feature = "redis"), "redis"),
3123            (cfg!(feature = "onnx"), "onnx"),
3124            (cfg!(feature = "qdrant"), "qdrant"),
3125        ] {
3126            let reported = missing
3127                .iter()
3128                .any(|message| message.contains(&format!("`{feature}`")));
3129            assert_eq!(
3130                reported, !enabled,
3131                "feature {feature}: enabled={enabled} but reported={reported} in {missing:?}"
3132            );
3133        }
3134    }
3135
3136    /// Each backend selector with a missing required companion fails
3137    /// with an error naming the companion field — the typed accessors
3138    /// make the invalid combination unrepresentable past parsing.
3139    #[test]
3140    fn backend_missing_companion_fails_with_named_error() {
3141        for (extra, companion) in [
3142            ("bridge_backend = \"kafka\"", "bridge_endpoint"),
3143            ("bridge_backend = \"nats\"", "bridge_endpoint"),
3144            (
3145                "bridge_backend = \"kafka\"\nbridge_endpoint = \"\"",
3146                "bridge_endpoint",
3147            ),
3148            ("state_backend = \"redis\"", "state_endpoint"),
3149            ("vector_backend = \"qdrant\"", "qdrant_url"),
3150            ("embedder_backend = \"onnx\"", "onnx_model_path"),
3151            (
3152                // One companion present, the other absent: still refused.
3153                "embedder_backend = \"onnx\"\nonnx_model_path = \"model.onnx\"",
3154                "onnx_tokenizer_path",
3155            ),
3156        ] {
3157            let err =
3158                HarnessConfig::from_toml(&format!("upstream_url = \"https://api\"\n{extra}")).unwrap_err();
3159            assert!(
3160                err.contains(companion) && err.contains("required"),
3161                "{extra}: error must name {companion}: {err}"
3162            );
3163        }
3164    }
3165
3166    /// An unknown backend value fails naming the exhaustive legal set,
3167    /// so a typo in `pvctl config-validate` output tells the operator
3168    /// exactly what the field accepts.
3169    #[test]
3170    fn unknown_backend_value_names_legal_set() {
3171        for (field, bogus, legal_set) in [
3172            ("bridge_backend", "carrier-pigeon", "embedded|kafka|nats"),
3173            ("state_backend", "abacus", "memory|redis"),
3174            ("embedder_backend", "vibes", "hash|onnx"),
3175            ("vector_backend", "faiss", "memory|qdrant"),
3176        ] {
3177            let err =
3178                HarnessConfig::from_toml(&format!("upstream_url = \"https://api\"\n{field} = \"{bogus}\""))
3179                    .unwrap_err();
3180            assert!(err.contains(field), "must name the field: {err}");
3181            assert!(err.contains(legal_set), "must name the legal set: {err}");
3182            assert!(err.contains(bogus), "must echo the typo: {err}");
3183        }
3184    }
3185
3186    /// A valid full-feature config resolves to the expected typed
3187    /// backend values, each variant carrying its companion.
3188    #[test]
3189    fn full_feature_config_resolves_to_typed_backends() {
3190        let cfg = HarnessConfig::from_toml(
3191            r#"upstream_url = "https://api"
3192               bridge_backend = "kafka"
3193               bridge_endpoint = "broker-1:9092,broker-2:9092"
3194               state_backend = "redis"
3195               state_endpoint = "redis://cache:6379"
3196               embedder_backend = "onnx"
3197               onnx_model_path = "models/model.onnx"
3198               onnx_tokenizer_path = "models/tokenizer.json"
3199               vector_backend = "qdrant"
3200               qdrant_url = "http://vectors:6333""#,
3201        )
3202        .unwrap();
3203        let backends = cfg.backends().unwrap();
3204        assert_eq!(
3205            backends.bridge,
3206            BridgeBackend::Kafka {
3207                endpoint: "broker-1:9092,broker-2:9092".to_owned()
3208            }
3209        );
3210        assert_eq!(
3211            backends.state,
3212            StateBackend::Redis {
3213                endpoint: "redis://cache:6379".to_owned()
3214            }
3215        );
3216        assert_eq!(
3217            backends.embedder,
3218            EmbedderBackend::Onnx {
3219                model_path: "models/model.onnx".to_owned(),
3220                tokenizer_path: "models/tokenizer.json".to_owned()
3221            }
3222        );
3223        assert_eq!(
3224            backends.vector,
3225            VectorBackend::Qdrant {
3226                url: "http://vectors:6333".to_owned()
3227            }
3228        );
3229        // The defaults resolve to the no-companion variants.
3230        let cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api""#).unwrap();
3231        let backends = cfg.backends().unwrap();
3232        assert_eq!(backends.bridge, BridgeBackend::Embedded);
3233        assert_eq!(backends.state, StateBackend::Memory);
3234        assert_eq!(backends.embedder, EmbedderBackend::Hash);
3235        assert_eq!(backends.vector, VectorBackend::Memory);
3236    }
3237
3238    /// `backends()` accumulates one error per unresolvable selector
3239    /// instead of stopping at the first, mirroring `validate()`.
3240    #[test]
3241    fn backends_accumulates_all_resolution_errors() {
3242        let mut cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api""#).unwrap();
3243        cfg.bridge_backend = "carrier-pigeon".to_owned();
3244        cfg.state_backend = "redis".to_owned(); // missing state_endpoint
3245        cfg.vector_backend = "faiss".to_owned();
3246        let errors = cfg.backends().unwrap_err();
3247        assert_eq!(errors.len(), 3, "{errors:?}");
3248        let joined = errors.join("\n");
3249        for needle in ["bridge_backend", "state_endpoint", "vector_backend"] {
3250            assert!(joined.contains(needle), "missing {needle} in {errors:?}");
3251        }
3252    }
3253
3254    /// A typo'd key must fail loudly (naming the offender) instead of being
3255    /// silently ignored; `config_version` handles forward compatibility.
3256    #[test]
3257    fn unknown_config_key_is_rejected_with_its_name() {
3258        let err = HarnessConfig::from_toml(
3259            r#"upstream_url = "https://api.openai.com"
3260               idel_timeout_s = 10"#,
3261        )
3262        .unwrap_err();
3263        assert!(err.contains("idel_timeout_s"), "error must name the key: {err}");
3264    }
3265
3266    /// The auth surface must reject every ambiguous or unsafe combination
3267    /// loudly at startup instead of picking one silently.
3268    #[test]
3269    fn upstream_auth_invariants_enforced() {
3270        // Both key sources set: ambiguous.
3271        assert!(HarnessConfig::from_toml(
3272            r#"upstream_url = "https://api.openai.com"
3273               upstream_api_key_env = "OPENAI_API_KEY"
3274               upstream_api_key_file = "/etc/key""#
3275        )
3276        .is_err());
3277        // Passthrough plus static key: ambiguous.
3278        assert!(HarnessConfig::from_toml(
3279            r#"upstream_url = "https://api.openai.com"
3280               upstream_api_key_env = "OPENAI_API_KEY"
3281               upstream_authorization_passthrough = true"#
3282        )
3283        .is_err());
3284        // Passthrough would forward the NHI token upstream.
3285        assert!(HarnessConfig::from_toml(
3286            r#"upstream_url = "https://api.openai.com"
3287               upstream_authorization_passthrough = true
3288               require_identity = true
3289               identity_hmac_secret_file = "/run/secrets/hmac""#
3290        )
3291        .is_err());
3292        // Invalid header name.
3293        assert!(HarnessConfig::from_toml(
3294            r#"upstream_url = "https://api.openai.com"
3295               upstream_auth_header = "not a header""#
3296        )
3297        .is_err());
3298        // Scheme with embedded space.
3299        assert!(HarnessConfig::from_toml(
3300            r#"upstream_url = "https://api.openai.com"
3301               upstream_auth_scheme = "Bearer extra""#
3302        )
3303        .is_err());
3304        // Chat path must be absolute.
3305        assert!(HarnessConfig::from_toml(
3306            r#"upstream_url = "https://api.openai.com"
3307               upstream_chat_path = "v1/chat/completions""#
3308        )
3309        .is_err());
3310        // Tool bearer without a tool upstream is a misconfiguration.
3311        assert!(HarnessConfig::from_toml(
3312            r#"upstream_url = "https://api.openai.com"
3313               tool_upstream_bearer_env = "MCP_TOKEN""#
3314        )
3315        .is_err());
3316        // Azure-style raw key header is valid.
3317        let azure = HarnessConfig::from_toml(
3318            r#"upstream_url = "https://res.openai.azure.com"
3319               upstream_chat_path = "/openai/deployments/gpt-4o/chat/completions?api-version=2024-10-21"
3320               upstream_api_key_env = "AZURE_OPENAI_API_KEY"
3321               upstream_auth_header = "api-key"
3322               upstream_auth_scheme = """#,
3323        )
3324        .unwrap();
3325        assert_eq!(azure.upstream_auth_scheme, "");
3326    }
3327
3328    #[test]
3329    fn env_overrides_apply_with_documented_precedence() {
3330        let mut cfg = HarnessConfig::for_tests("http://file-upstream", "spool", "bridge");
3331        let env = |name: &str| -> Option<String> {
3332            match name {
3333                "PV_LISTEN" => Some("0.0.0.0:9999".into()),
3334                "PV_UPSTREAM_URL" => Some("http://env-upstream".into()),
3335                "PV_UPSTREAM_CHAT_PATH" => Some("/openai/v1/chat/completions".into()),
3336                "PV_UPSTREAM_API_KEY" => Some("sk-secret".into()),
3337                "PV_STATE_ENDPOINT" => Some("redis://redis:6379".into()),
3338                _ => None,
3339            }
3340        };
3341        cfg.apply_env_overrides_from(env);
3342        assert_eq!(cfg.listen, "0.0.0.0:9999");
3343        assert_eq!(cfg.upstream_url, "http://env-upstream");
3344        assert_eq!(cfg.upstream_chat_path, "/openai/v1/chat/completions");
3345        assert_eq!(cfg.state_endpoint.as_deref(), Some("redis://redis:6379"));
3346        // PV_UPSTREAM_API_KEY presence selects itself as the key source...
3347        assert_eq!(cfg.upstream_api_key_env.as_deref(), Some("PV_UPSTREAM_API_KEY"));
3348
3349        // ...but never displaces an explicitly configured source.
3350        let mut cfg = HarnessConfig::for_tests("http://file-upstream", "spool", "bridge");
3351        cfg.upstream_api_key_env = Some("OPENAI_API_KEY".into());
3352        cfg.apply_env_overrides_from(env);
3353        assert_eq!(cfg.upstream_api_key_env.as_deref(), Some("OPENAI_API_KEY"));
3354
3355        // Empty environment values are ignored rather than blanking fields.
3356        let mut cfg = HarnessConfig::for_tests("http://file-upstream", "spool", "bridge");
3357        cfg.apply_env_overrides_from(|name| (name == "PV_UPSTREAM_URL").then(String::new));
3358        assert_eq!(cfg.upstream_url, "http://file-upstream");
3359    }
3360
3361    #[test]
3362    fn env_key_file_override_precedence() {
3363        // PV_UPSTREAM_KEY_FILE selects the mounted-secret file...
3364        let env = |name: &str| -> Option<String> {
3365            match name {
3366                "PV_UPSTREAM_KEY_FILE" => Some("/run/secrets/api_key".into()),
3367                "PV_UPSTREAM_API_KEY" => Some("sk-secret".into()),
3368                _ => None,
3369            }
3370        };
3371        let mut cfg = HarnessConfig::for_tests("http://u", "spool", "bridge");
3372        cfg.apply_env_overrides_from(env);
3373        assert_eq!(cfg.upstream_api_key_file.as_deref(), Some("/run/secrets/api_key"));
3374        // ...and beats the PV_UPSTREAM_API_KEY self-selection (no ambiguity).
3375        assert_eq!(cfg.upstream_api_key_env, None);
3376
3377        // But never displaces a config-file key source.
3378        let mut cfg = HarnessConfig::for_tests("http://u", "spool", "bridge");
3379        cfg.upstream_api_key_env = Some("OPENAI_API_KEY".into());
3380        cfg.apply_env_overrides_from(env);
3381        assert_eq!(cfg.upstream_api_key_env.as_deref(), Some("OPENAI_API_KEY"));
3382        assert_eq!(cfg.upstream_api_key_file, None);
3383
3384        // And is ignored entirely in passthrough mode.
3385        let mut cfg = HarnessConfig::for_tests("http://u", "spool", "bridge");
3386        cfg.upstream_authorization_passthrough = true;
3387        cfg.apply_env_overrides_from(env);
3388        assert_eq!(cfg.upstream_api_key_file, None);
3389        assert_eq!(cfg.upstream_api_key_env, None);
3390    }
3391
3392    #[test]
3393    fn builtin_config_validates_once_upstream_is_set() {
3394        let mut cfg = HarnessConfig::builtin().unwrap();
3395        assert!(cfg.validate().is_err(), "must not pass without an upstream");
3396        cfg.apply_env_overrides_from(|name| {
3397            (name == "PV_UPSTREAM_URL").then(|| "http://127.0.0.1:11434".to_owned())
3398        });
3399        cfg.validate().unwrap();
3400        assert_eq!(cfg.bridge_backend, "embedded");
3401        assert_eq!(cfg.state_backend, "memory");
3402        assert_eq!(cfg.embedder_backend, "hash");
3403        assert_eq!(cfg.vector_backend, "memory");
3404    }
3405
3406    #[test]
3407    fn validate_rejects_unbindable_listen_and_zero_intervals() {
3408        let base = || HarnessConfig::for_tests("http://u", "spool", "bridge");
3409
3410        let mut cfg = base();
3411        cfg.listen = String::new();
3412        assert!(cfg.validate().unwrap_err().contains("listen is required"));
3413
3414        cfg = base();
3415        cfg.listen = "no-port-here".into();
3416        assert!(cfg.validate().unwrap_err().contains("host:port"));
3417
3418        cfg = base();
3419        cfg.listen = "127.0.0.1:70000".into();
3420        assert!(cfg.validate().unwrap_err().contains("host:port"));
3421
3422        // Hostnames and OS-assigned port 0 stay legal (bind resolves them).
3423        cfg = base();
3424        cfg.listen = "localhost:8484".into();
3425        cfg.validate().unwrap();
3426        cfg.listen = "[::1]:0".into();
3427        cfg.validate().unwrap();
3428
3429        cfg = base();
3430        cfg.reconcile_tick_s = 0;
3431        assert!(cfg.validate().unwrap_err().contains("reconcile_tick_s"));
3432
3433        cfg = base();
3434        cfg.session_idle_close_s = 0;
3435        assert!(cfg.validate().unwrap_err().contains("session_idle_close_s"));
3436
3437        // `atif_retention_days = Some(0)` would nuke every sealed pair
3438        // on the first hourly sweep (max_age = 0 → `age < 0` false for
3439        // all mtimes → all pruned). Validate must reject it; `None`
3440        // (disable) and `Some(1)`+ (real windows) stay legal.
3441        cfg = base();
3442        cfg.atif_retention_days = Some(0);
3443        assert!(
3444            cfg.validate().unwrap_err().contains("atif_retention_days = 0"),
3445            "atif_retention_days = 0 should be rejected"
3446        );
3447        cfg.atif_retention_days = None;
3448        cfg.validate().unwrap();
3449        cfg.atif_retention_days = Some(1);
3450        cfg.validate().unwrap();
3451
3452        // R69 F3 / R70: BudgetSpec `Some(0)` caps silently 429-storm
3453        // every non-empty request against the ledger's refuse-any-
3454        // spend semantics. Reject at config-validate; None (uncapped)
3455        // and Some(>=1) (real cap) stay legal.
3456        cfg = base();
3457        cfg.budget.max_tokens = Some(0);
3458        assert!(
3459            cfg.validate().unwrap_err().contains("budget.max_tokens"),
3460            "budget.max_tokens = 0 should be rejected"
3461        );
3462        cfg = base();
3463        cfg.budget.max_total_tool_calls = Some(0);
3464        assert!(
3465            cfg.validate()
3466                .unwrap_err()
3467                .contains("budget.max_total_tool_calls"),
3468            "budget.max_total_tool_calls = 0 should be rejected"
3469        );
3470        cfg = base();
3471        cfg.budget.max_payout_usd_micros = Some(0);
3472        assert!(
3473            cfg.validate()
3474                .unwrap_err()
3475                .contains("budget.max_payout_usd_micros"),
3476            "budget.max_payout_usd_micros = 0 should be rejected"
3477        );
3478        cfg = base();
3479        cfg.budget.max_tool_calls = std::collections::BTreeMap::from([("db_write".to_owned(), 0u64)]);
3480        assert!(
3481            cfg.validate()
3482                .unwrap_err()
3483                .contains("max_tool_calls[\"db_write\"]"),
3484            "budget.max_tool_calls[db_write] = 0 should be rejected"
3485        );
3486        // Same discipline applies to the per-principal budget spec.
3487        cfg = base();
3488        cfg.require_identity = true;
3489        cfg.identity_jwks_url = Some("https://example/.well-known/jwks.json".to_owned());
3490        cfg.principal_budget = Some(pv_state::BudgetSpec {
3491            max_tokens: Some(0),
3492            ..pv_state::BudgetSpec::default()
3493        });
3494        assert!(
3495            cfg.validate()
3496                .unwrap_err()
3497                .contains("principal_budget.max_tokens"),
3498            "principal_budget.max_tokens = 0 should be rejected"
3499        );
3500        // Uncapped (None) stays legal on both.
3501        cfg = base();
3502        cfg.budget = pv_state::BudgetSpec::default();
3503        cfg.validate().unwrap();
3504
3505        cfg = base();
3506        cfg.breaker.window = 0;
3507        assert!(cfg.validate().unwrap_err().contains("breaker.window"));
3508
3509        cfg = base();
3510        cfg.breaker.delta_epsilon = f32::NAN;
3511        assert!(cfg.validate().unwrap_err().contains("delta_epsilon"));
3512        cfg.breaker.delta_epsilon = -0.5;
3513        assert!(cfg.validate().unwrap_err().contains("delta_epsilon"));
3514    }
3515
3516    /// Register item 25 sub-clause: "`PV_SIGNING_SEED_FILE` is in zero
3517    /// .md files" — the exact drift the reference table was supposed to
3518    /// prevent. Pass 18 pinned config-file field completeness, but env
3519    /// vars had no completeness guard, so `PV_SIGNING_SEED_FILE` could
3520    /// silently vanish again on a future edit. Every operator-facing
3521    /// `PV_*` env var must appear in at least one reference document.
3522    /// The test-only `PV_HARBOR_INTEROP_OUT` (a Harbor-interop escape
3523    /// hatch scoped to `full_chat_close_and_promotion_flow`) is not
3524    /// operator-facing and is deliberately excluded.
3525    #[test]
3526    fn operator_env_vars_are_documented() {
3527        let refs = [
3528            include_str!("../../../docs/reference/CONFIGURATION.md"),
3529            include_str!("../../../docs/reference/OPERATIONS.md"),
3530            include_str!("../../../docs/reference/OPENAI-COMPATIBILITY.md"),
3531            include_str!("../../../docs/reference/LIMITS.md"),
3532        ]
3533        .concat();
3534        for var in [
3535            // Config resolution (zero-config startup and file override).
3536            "PV_CONFIG",
3537            "PV_UPSTREAM_URL",
3538            // Trust anchor path — the register item-25 explicit callout.
3539            "PV_SIGNING_SEED_FILE",
3540            // Bearer token loader used by `pvctl session-promote`.
3541            "PV_BEARER_TOKEN_FILE",
3542        ] {
3543            assert!(
3544                refs.contains(var),
3545                "operator-facing env var `{var}` is not documented in any \
3546                 docs/reference/*.md — register item 25 explicitly names the \
3547                 undocumented PV_ env var class as the drift signature."
3548            );
3549        }
3550    }
3551
3552    #[test]
3553    fn user_config_path_is_stable() {
3554        let path = user_config_path_from(std::path::Path::new("/home/pat"));
3555        assert_eq!(
3556            path,
3557            std::path::Path::new("/home/pat/.proofvisor/proofvisor.toml")
3558        );
3559    }
3560
3561    /// Pin the register-item-6 fix behaviorally. The original register
3562    /// complained: "Wizard-then-`pvctl start` inside a checkout silently
3563    /// loads the example config: wrong bind, dead upstream, no API key,
3564    /// no error." The fix was to REMOVE `config/harness.example.toml`
3565    /// from the auto-discovery list entirely (better than the register's
3566    /// requested rank swap): the example is a template, not a config.
3567    /// If someone adds it back — well-meaning "make the example work
3568    /// out of the box" — every developer with a wizard-written
3569    /// `~/.proofvisor/proofvisor.toml` silently regresses to the
3570    /// example's settings the moment they `cd` into a checkout.
3571    #[test]
3572    fn config_search_paths_never_include_the_example_config() {
3573        for path in CONFIG_SEARCH_PATHS {
3574            assert!(
3575                !path.contains("example"),
3576                "CONFIG_SEARCH_PATHS must not auto-discover a `*example*` file: {path:?}. \
3577                 See the doc-comment on CONFIG_SEARCH_PATHS — the example is a template, \
3578                 not a config, and auto-loading it defeats the wizard-written per-user file."
3579            );
3580        }
3581        // Also pin: the per-user wizard file must sit at the documented
3582        // location, so `pvctl init --output ~/.proofvisor/proofvisor.toml`
3583        // (the wizard's actual write path) is what `resolve_config_source`
3584        // will find in the fall-through arm.
3585        assert_eq!(
3586            user_config_path_from(std::path::Path::new("/h")),
3587            std::path::PathBuf::from("/h/.proofvisor/proofvisor.toml"),
3588            "the wizard-written path is contract; changing it breaks every operator \
3589             whose home already has this file"
3590        );
3591    }
3592
3593    /// The `/v1` suffix footgun must be detected exactly: flagged when the
3594    /// base URL already ends with the first chat-path segment, silent for
3595    /// bare hosts, ports, and provider paths that do not overlap.
3596    #[test]
3597    fn duplicated_chat_path_segment_detection() {
3598        let cfg = |url: &str| HarnessConfig::for_tests(url, "spool", "bridge");
3599        assert_eq!(
3600            cfg("https://api.openai.com/v1").duplicated_chat_path_segment(),
3601            Some("v1")
3602        );
3603        assert_eq!(
3604            cfg("https://api.openai.com/v1/").duplicated_chat_path_segment(),
3605            Some("v1")
3606        );
3607        assert_eq!(
3608            cfg("http://localhost:8080/v1").duplicated_chat_path_segment(),
3609            Some("v1")
3610        );
3611        assert_eq!(cfg("https://api.openai.com").duplicated_chat_path_segment(), None);
3612        assert_eq!(cfg("http://127.0.0.1:11434").duplicated_chat_path_segment(), None);
3613        // Gemini-style base path that does not repeat the chat path.
3614        let mut gemini = cfg("https://generativelanguage.googleapis.com/v1beta/openai");
3615        gemini.upstream_chat_path = "/chat/completions".into();
3616        assert_eq!(gemini.duplicated_chat_path_segment(), None);
3617        // Azure-style custom path with a matching base suffix still flags.
3618        let mut azure = cfg("https://r.openai.azure.com/openai");
3619        azure.upstream_chat_path = "/openai/deployments/d/chat/completions".into();
3620        assert_eq!(azure.duplicated_chat_path_segment(), Some("openai"));
3621        // A pasted full endpoint URL embeds the entire chat path.
3622        assert_eq!(
3623            cfg("http://10.0.0.5:8000/v1/chat/completions").duplicated_chat_path_segment(),
3624            Some("v1/chat/completions")
3625        );
3626        assert_eq!(
3627            cfg("http://10.0.0.5:8000/v1/chat/completions/").duplicated_chat_path_segment(),
3628            Some("v1/chat/completions")
3629        );
3630    }
3631
3632    #[test]
3633    fn bad_configs_rejected() {
3634        assert!(HarnessConfig::from_toml("").is_err()); // missing upstream
3635        assert!(HarnessConfig::from_toml(
3636            r#"upstream_url = "https://api"
3637               config_version = 99"#
3638        )
3639        .is_err());
3640        // Each case below must reach the check it names, not fall
3641        // through to `upstream_url` scheme validation — otherwise
3642        // deleting the specific validator here still leaves the outer
3643        // is_err() true. Use a valid upstream_url and pin the actual
3644        // rejection reason.
3645        let bad_workflow = HarnessConfig::from_toml(
3646            r#"upstream_url = "https://api"
3647               default_workflow = "sometimes""#,
3648        )
3649        .unwrap_err();
3650        assert!(
3651            bad_workflow.contains("default_workflow"),
3652            "the default_workflow allowlist must be what refuses this config, \
3653             not the upstream_url scheme check via fallthrough; got {bad_workflow}"
3654        );
3655        let bad_capacity = HarnessConfig::from_toml(
3656            r#"upstream_url = "https://api"
3657               worker_channel_capacity = 0"#,
3658        )
3659        .unwrap_err();
3660        assert!(
3661            bad_capacity.contains("worker_channel_capacity"),
3662            "the worker_channel_capacity bounds check must be what refuses this \
3663             config, not the upstream_url scheme check via fallthrough; got {bad_capacity}"
3664        );
3665    }
3666
3667    /// A config from a newer format version must be refused by its declared
3668    /// `config_version` — not by whichever unknown key the strict parser
3669    /// happens to trip on first.
3670    #[test]
3671    fn future_version_config_reports_version_not_unknown_field() {
3672        let err = HarnessConfig::from_toml(
3673            r#"config_version = 2
3674               upstream_url = "https://api"
3675               future_option_from_v2 = true"#,
3676        )
3677        .unwrap_err();
3678        assert!(err.contains("unsupported config_version 2"), "{err}");
3679        assert!(!err.contains("future_option_from_v2"), "{err}");
3680    }
3681
3682    /// Cap on `worker_channel_capacity` rejects the fat-finger config
3683    /// before it reaches the runtime (defence-in-depth; tokio's mpsc
3684    /// does not preallocate — see the `MAX_WORKER_CHANNEL_CAPACITY` doc).
3685    #[test]
3686    fn worker_channel_capacity_cap_rejects_oversized_values() {
3687        let err = HarnessConfig::from_toml(&format!(
3688            "upstream_url = \"https://api\"\nworker_channel_capacity = {}",
3689            MAX_WORKER_CHANNEL_CAPACITY + 1
3690        ))
3691        .unwrap_err();
3692        assert!(
3693            err.contains("worker_channel_capacity"),
3694            "err should name the offending field: {err}"
3695        );
3696    }
3697
3698    /// `upstream_url` without a scheme is rejected at load — otherwise
3699    /// the request-time concat would silently misroute to a bogus host.
3700    /// The shape check was tightened from `contains("://")` to
3701    /// the strict `http://` / `https://` allowlist, matching the
3702    /// posture on every other URL field.
3703    #[test]
3704    fn upstream_url_without_scheme_is_rejected() {
3705        let err = HarnessConfig::from_toml(r#"upstream_url = "openai.internal""#).unwrap_err();
3706        assert!(err.contains("upstream_url"), "{err}");
3707        assert!(err.contains("http"), "{err}");
3708    }
3709
3710    /// Schemes other than http/https are rejected. The
3711    /// prior `contains("://")` shape check accepted `file:///…` and
3712    /// other schemes even though the error text claimed http/https;
3713    /// a config-injection primitive could have pointed the harness
3714    /// at `file:///etc/passwd`. `pvctl config-validate` now refuses.
3715    #[test]
3716    fn upstream_url_non_http_scheme_is_rejected() {
3717        let err = HarnessConfig::from_toml(r#"upstream_url = "file:///etc/passwd""#).unwrap_err();
3718        assert!(err.contains("upstream_url"), "{err}");
3719        let err = HarnessConfig::from_toml(r#"upstream_url = "gopher://x""#).unwrap_err();
3720        assert!(err.contains("upstream_url"), "{err}");
3721        // http and https are the only accepted schemes.
3722        assert!(HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).is_ok());
3723        assert!(HarnessConfig::from_toml(r#"upstream_url = "http://gw.local""#).is_ok());
3724    }
3725
3726    /// A scheme with no host (`http://`, `https:///v1`) passed the
3727    /// prefix preflight, booted, and validated cleanly, failing only at
3728    /// request time with a 502. Refuse it at startup like every other
3729    /// URL-shape defect.
3730    #[test]
3731    fn upstream_url_without_host_is_rejected() {
3732        for url in [r#"upstream_url = "http://""#, r#"upstream_url = "https:///v1""#] {
3733            let err = HarnessConfig::from_toml(url).unwrap_err();
3734            assert!(err.contains("no host"), "{err}");
3735        }
3736    }
3737
3738    /// The shipped example config must PARSE AND VALIDATE, and must
3739    /// pin the register-#4 posture: loopback bind, dashboard off, and
3740    /// every uncommented value acceptable to the strict loader. This
3741    /// is the file the README tells checkout users to copy — nothing
3742    /// exercised it before, which is exactly how the README's
3743    /// checkout section drifted stale. All three shipped configs must
3744    /// also opt into the signed workflow: the serde default is
3745    /// "unsigned" (zero-config ergonomics), so any shipped config
3746    /// that omits the key silently produces ZERO signed receipts on
3747    /// clean traffic — the register's item-19 observation, found
3748    /// live in the container/docker configs and the K8s ConfigMap.
3749    #[test]
3750    fn shipped_example_config_parses_and_pins_safe_posture() {
3751        let raw = include_str!("../../../config/harness.example.toml");
3752        let config = HarnessConfig::from_toml(raw)
3753            .unwrap_or_else(|error| panic!("shipped example config must validate: {error}"));
3754        assert_eq!(config.listen, "127.0.0.1:8484", "example must bind loopback");
3755        assert!(!config.dashboard_enabled, "example must ship dashboard-off");
3756        assert_eq!(
3757            config.budget.max_tokens,
3758            Some(200_000),
3759            "example must bind a token budget"
3760        );
3761        assert_eq!(config.default_workflow, "signed");
3762        assert_eq!(
3763            config.redaction_patterns, RECOMMENDED_REDACTION_PATTERNS,
3764            "example config must turn redaction on with the recommended patterns"
3765        );
3766        for (name, raw) in [
3767            (
3768                "container",
3769                include_str!("../../../config/harness.container.toml"),
3770            ),
3771            ("docker", include_str!("../../../config/harness.docker.toml")),
3772        ] {
3773            let config = HarnessConfig::from_toml(raw)
3774                .unwrap_or_else(|error| panic!("shipped {name} config must validate: {error}"));
3775            assert_eq!(
3776                config.default_workflow, "signed",
3777                "{name} config must opt into the signed workflow"
3778            );
3779            // The product documentation promises that secrets are redacted
3780            // before anything is persisted. The pipeline only builds the
3781            // redaction engine when a pattern or path list is non-empty, so
3782            // a shipped config that omits the key silently persists raw
3783            // API keys, emails, and card numbers.
3784            assert_eq!(
3785                config.redaction_patterns, RECOMMENDED_REDACTION_PATTERNS,
3786                "{name} config must turn redaction on with the recommended patterns"
3787            );
3788        }
3789    }
3790
3791    /// The recommended pattern list must compile together with the
3792    /// built-ins, must not match the replacement marker, must catch the
3793    /// credential formats it names, and must leave look-alike prose
3794    /// alone.
3795    #[test]
3796    fn recommended_redaction_patterns_compile_and_match_their_targets() {
3797        for pattern in RECOMMENDED_REDACTION_PATTERNS {
3798            assert!(
3799                !pattern.contains('\''),
3800                "{pattern:?} cannot be written as a TOML literal string"
3801            );
3802        }
3803        let engine = pv_redact::RedactionEngine::new(pv_redact::RedactionConfig {
3804            regex_patterns: RECOMMENDED_REDACTION_PATTERNS
3805                .iter()
3806                .map(|pattern| (*pattern).to_owned())
3807                .collect(),
3808            ..pv_redact::RedactionConfig::default()
3809        })
3810        .unwrap();
3811        for (input, expected) in [
3812            ("aws AKIAIOSFODNN7EXAMPLE here", "aws [REDACTED] here"),
3813            (
3814                "gh ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij done",
3815                "gh [REDACTED] done",
3816            ),
3817            (
3818                "g AIzaSyA-1234567890abcdefghijklmnopqrstu end",
3819                "g [REDACTED] end",
3820            ),
3821            ("slack xoxb-1234567890-abcdef end", "slack [REDACTED] end"),
3822            (
3823                "jwt eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.abcdefghijklmnop end",
3824                "jwt [REDACTED] end",
3825            ),
3826            // Built-ins still apply once the list is non-empty.
3827            ("mail alice@example.org", "mail [REDACTED]"),
3828        ] {
3829            let mut value = serde_json::Value::String(input.to_owned());
3830            engine.redact_value(&mut value);
3831            assert_eq!(value, serde_json::Value::String(expected.to_owned()), "{input}");
3832        }
3833        for benign in [
3834            "ghost_stories are fun",
3835            "xoxo",
3836            "AKIA is a prefix",
3837            "eyJ is how base64 JSON starts",
3838            "the AIza prefix",
3839        ] {
3840            let mut value = serde_json::Value::String(benign.to_owned());
3841            engine.redact_value(&mut value);
3842            assert_eq!(value, serde_json::Value::String(benign.to_owned()));
3843        }
3844    }
3845
3846    /// Register pass 35 finding — the four shipped tool schemas under
3847    /// config/tool-schemas/ are the daemon's boot-time contract: at
3848    /// startup `load_sandbox` reads each `.json` file and parses it
3849    /// with `serde_json::from_slice`, using the file stem as the tool
3850    /// name. Any parse failure with default `tool_schema_dir =
3851    /// "config/tool-schemas"` errors out the daemon with
3852    /// "parse tool schema {path}: ..." — a CrashLoopBackOff on
3853    /// Kubernetes, an immediate exit on bare-metal.
3854    ///
3855    /// Only db_write.json was parse-tested before this pass (via
3856    /// `tests/sla.rs:33`, which include_str!s it). The other three —
3857    /// payout.json, deploy.json, merge.json — shipped and were
3858    /// referenced by config/harness.example.toml + the K8s ConfigMap,
3859    /// but nothing exercised them at CI time.
3860    #[test]
3861    fn shipped_tool_schemas_parse_as_json() {
3862        for (name, raw) in [
3863            (
3864                "payout.json",
3865                include_str!("../../../config/tool-schemas/payout.json"),
3866            ),
3867            (
3868                "db_write.json",
3869                include_str!("../../../config/tool-schemas/db_write.json"),
3870            ),
3871            (
3872                "deploy.json",
3873                include_str!("../../../config/tool-schemas/deploy.json"),
3874            ),
3875            (
3876                "merge.json",
3877                include_str!("../../../config/tool-schemas/merge.json"),
3878            ),
3879        ] {
3880            let value: serde_json::Value = serde_json::from_str(raw).unwrap_or_else(|error| {
3881                panic!(
3882                    "shipped tool schema {name} must parse as JSON — the daemon calls \
3883                     serde_json::from_slice on each .json file under tool_schema_dir at \
3884                     boot; a parse failure here CrashLoopBackOffs the pod. Error: {error}"
3885                )
3886            });
3887            // The schema must be an object at the top level — that is
3888            // what jsonschema treats as a "schema for a value"; a bare
3889            // array or scalar would compile as "any input matches",
3890            // silently gating nothing. Fail-closed at CI, not at
3891            // request time.
3892            assert!(
3893                value.is_object(),
3894                "shipped tool schema {name} must be a JSON object at the top level \
3895                 (JSON Schema convention); a bare array/scalar would silently accept \
3896                 every tool call"
3897            );
3898        }
3899    }
3900
3901    /// Register pass 34 finding — the K8s ConfigMap embeds a full
3902    /// TOML block for the daemon at deploy/kubernetes/proofvisor.
3903    /// yaml. It ships with the shipped `.toml` files pinned above
3904    /// but the ConfigMap's OWN copy of the config was never
3905    /// parse-tested. If it drifts (typo in a field name, unknown
3906    /// key from a rebase, a rename in HarnessConfig with no matching
3907    /// yaml edit), `kubectl apply` succeeds — YAML syntax is fine —
3908    /// but every pod CrashLoopBackOffs on boot with the classic
3909    /// "unknown field `X`, expected one of ..." serde error. The
3910    /// operator debugging this can't tell the daemon from the config
3911    /// apart without shell access to the pod.
3912    ///
3913    /// This test extracts the embedded TOML at compile time,
3914    /// un-indents it (each line has a 4-space YAML block indent),
3915    /// runs it through the same strict loader `proofvisord` uses at
3916    /// boot, and pins the same safe-posture invariants as the
3917    /// shipped configs above.
3918    #[test]
3919    fn shipped_kubernetes_configmap_toml_parses_and_pins_safe_posture() {
3920        let yaml = include_str!("../../../deploy/kubernetes/proofvisor.yaml");
3921        // Find the block scalar. The ConfigMap's `proofvisor.toml: |`
3922        // header opens a literal block; the block continues while
3923        // subsequent lines are indented at the block's level or
3924        // deeper (or blank). The next YAML-level key or `---`
3925        // separator ends it.
3926        let block_header = "proofvisor.toml: |";
3927        let block_start = yaml
3928            .find(block_header)
3929            .unwrap_or_else(|| panic!("K8s manifest must embed a `{block_header}` block"))
3930            + block_header.len();
3931        let after = &yaml[block_start..];
3932        let after = after.trim_start_matches('\n');
3933        // Every line of the block is indented at least 4 spaces
3934        // (see the ConfigMap in the manifest). Take lines until we
3935        // hit a non-blank line whose indent is smaller.
3936        let mut toml = String::new();
3937        for line in after.lines() {
3938            if line.is_empty() || line.trim().is_empty() {
3939                toml.push('\n');
3940                continue;
3941            }
3942            if let Some(stripped) = line.strip_prefix("    ") {
3943                toml.push_str(stripped);
3944                toml.push('\n');
3945            } else {
3946                break;
3947            }
3948        }
3949        assert!(
3950            toml.contains("config_version"),
3951            "extracted ConfigMap TOML looks empty; did the block indent change?"
3952        );
3953
3954        let config = HarnessConfig::from_toml(&toml).unwrap_or_else(|error| {
3955            panic!(
3956                "K8s ConfigMap proofvisor.toml block must parse and validate under \
3957                 the same strict loader proofvisord uses; a rename/typo here \
3958                 CrashLoopBackOffs every pod at boot. Error: {error}\n\n\
3959                 Extracted TOML:\n{toml}"
3960            )
3961        });
3962
3963        // Same safe-posture pins as the shipped configs. Adding the
3964        // K8s ConfigMap to the roster the register's item 4 concern
3965        // covers.
3966        assert_eq!(
3967            config.default_workflow, "signed",
3968            "K8s ConfigMap must opt into the signed workflow (register item 19); \
3969             the serde default `unsigned` silently mints zero receipts on clean \
3970             traffic"
3971        );
3972        assert!(
3973            !config.dashboard_enabled,
3974            "K8s ConfigMap must ship dashboard-off — the pod binds 0.0.0.0 for \
3975             cluster network reachability, so any workload with Service access \
3976             could otherwise enumerate sessions/costs/receipts (register item 4)"
3977        );
3978        assert!(config.require_identity, "K8s workloads must authenticate callers");
3979        assert!(
3980            !config.allow_wildcard_bind,
3981            "anonymous wildcard binding must not be enabled"
3982        );
3983        assert_eq!(config.audience, "agentvisor-ai");
3984        assert_eq!(
3985            config.identity_hmac_secret_file.as_deref(),
3986            Some("/etc/proofvisor/identity.hmac")
3987        );
3988        assert_eq!(
3989            config.state_backend, "redis",
3990            "production revocations must survive restart"
3991        );
3992        assert_eq!(
3993            config.state_endpoint.as_deref(),
3994            Some("rediss://redis.example.invalid:6379")
3995        );
3996        assert!(yaml.contains("name: PV_STATE_ENDPOINT"));
3997        assert!(yaml.contains("name: proofvisor-state"));
3998        assert!(yaml.contains("key: redis-url"));
3999    }
4000
4001    #[test]
4002    fn shipped_cloudfoundry_config_requires_identity_and_durable_revocations() {
4003        let config = HarnessConfig::from_toml(include_str!("../../../deploy/cloudfoundry/harness.toml"))
4004            .expect("Cloud Foundry configuration must validate");
4005        assert!(config.require_identity);
4006        assert!(!config.dashboard_enabled);
4007        assert!(!config.allow_wildcard_bind);
4008        assert_eq!(config.audience, "agentvisor-ai");
4009        assert_eq!(config.state_backend, "redis");
4010        assert_eq!(
4011            config.state_endpoint.as_deref(),
4012            Some("rediss://redis.example.invalid:6379")
4013        );
4014        let manifest = include_str!("../../../deploy/cloudfoundry/manifest.yml");
4015        assert!(manifest.contains("PV_STATE_ENDPOINT: ((redis_url))"));
4016    }
4017
4018    /// The K8s manifest ships a specific `terminationGracePeriodSeconds`
4019    /// that must cover the daemon's worst-case shutdown budget for the
4020    /// ConfigMap TOML it also ships. If the ConfigMap's
4021    /// `shutdown_drain_timeout_s` (or its derivation from
4022    /// `upstream_read_timeout_s`) pushes the total above the grace, a
4023    /// rolling deploy under a long-tail request SIGKILLs mid-finalize
4024    /// and drops receipts — the exact outcome the graceful-shutdown
4025    /// machinery exists to prevent. Compute both from the SAME
4026    /// manifest so a future edit that touches only one is caught here.
4027    #[test]
4028    fn shipped_kubernetes_configmap_total_shutdown_fits_termination_grace() {
4029        let yaml = include_str!("../../../deploy/kubernetes/proofvisor.yaml");
4030        // Parse the ConfigMap TOML the same way the sibling test does.
4031        let block_header = "proofvisor.toml: |";
4032        let block_start = yaml.find(block_header).unwrap() + block_header.len();
4033        let after = yaml[block_start..].trim_start_matches('\n');
4034        let mut toml = String::new();
4035        for line in after.lines() {
4036            if line.trim().is_empty() {
4037                toml.push('\n');
4038                continue;
4039            }
4040            if let Some(stripped) = line.strip_prefix("    ") {
4041                toml.push_str(stripped);
4042                toml.push('\n');
4043            } else {
4044                break;
4045            }
4046        }
4047        let config = HarnessConfig::from_toml(&toml).unwrap();
4048        assert_total_shutdown_fits(
4049            "K8s manifest ConfigMap",
4050            &config,
4051            extract_yaml_int(yaml, "terminationGracePeriodSeconds:").unwrap(),
4052            // K8s runs preStop synchronously BEFORE SIGTERM and both
4053            // count against the grace period. If the manifest has a
4054            // preStop hook, its sleep MUST be parseable — a silent
4055            // `unwrap_or(0)` here defeats the pin whenever the hook is
4056            // reformatted (YAML block form, single-quoted strings, a
4057            // mechanism other than `sleep`), which is exactly the drift
4058            // class the test exists to catch.
4059            require_prestop_sleep_seconds(yaml),
4060        );
4061    }
4062
4063    /// docker-compose.yml + config/harness.docker.toml pair (the
4064    /// hardened stack). Same drift class as the K8s test above: a
4065    /// tightening of `stop_grace_period` (or a bump in
4066    /// `shutdown_drain_timeout_s`) that individually looks safe can
4067    /// push the sum over the grace and silently start dropping
4068    /// receipts on `docker compose down`.
4069    #[test]
4070    fn shipped_docker_compose_stop_grace_fits_harness_docker_shutdown() {
4071        let compose = include_str!("../../../docker/docker-compose.yml");
4072        let toml = include_str!("../../../config/harness.docker.toml");
4073        let config = HarnessConfig::from_toml(toml).unwrap();
4074        assert_total_shutdown_fits(
4075            "docker-compose.yml / harness.docker.toml",
4076            &config,
4077            extract_yaml_grace_seconds(compose, "stop_grace_period:").unwrap(),
4078            // Docker compose has no preStop equivalent.
4079            0,
4080        );
4081    }
4082
4083    #[test]
4084    fn shipped_full_docker_config_disables_unauthenticated_dashboard() {
4085        let config = HarnessConfig::from_toml(include_str!("../../../config/harness.docker.toml")).unwrap();
4086        assert!(config.require_identity, "full-stack callers must authenticate");
4087        assert!(
4088            config.enforce_identity_scopes,
4089            "full-stack callers need operation scopes"
4090        );
4091        assert!(
4092            !config.dashboard_enabled,
4093            "Compose peers can reach the listener without the host loopback port mapping"
4094        );
4095        assert!(
4096            !config.allow_wildcard_bind,
4097            "the authenticated full stack must not bypass the wildcard safety guards"
4098        );
4099    }
4100
4101    /// docker-compose.minimal.yml + config/harness.container.toml pair
4102    /// (the evaluator on-ramp). Advertised in the README as the
4103    /// zero-friction try-it flow, so an unclean `docker stop` here is
4104    /// exactly the shape a prospect encounters when they Ctrl-C.
4105    #[test]
4106    fn shipped_docker_compose_minimal_stop_grace_fits_container_shutdown() {
4107        let compose = include_str!("../../../docker/docker-compose.minimal.yml");
4108        let toml = include_str!("../../../config/harness.container.toml");
4109        let config = HarnessConfig::from_toml(toml).unwrap();
4110        assert_total_shutdown_fits(
4111            "docker-compose.minimal.yml / harness.container.toml",
4112            &config,
4113            extract_yaml_grace_seconds(compose, "stop_grace_period:").unwrap(),
4114            0,
4115        );
4116    }
4117
4118    /// Shared budget check. The four shutdown phases in finish_shutdown
4119    /// run sequentially, so the worst-case wall time is the sum of
4120    /// each phase's cap plus any pre-drain wall time the deployment
4121    /// requires BEFORE the phases start.
4122    ///   1. HTTP drain: `effective_drain_timeout()` (config-derived).
4123    ///   2. Worker wait_idle: [`WORKER_FINALIZE_PHASE_SECS`] — imported
4124    ///      from the same source of truth `main.rs`'s `finish_shutdown`
4125    ///      call site uses, so a bump there without matching bumps in
4126    ///      the deployment manifests trips this test at CI time.
4127    ///   3. finalize_sessions: same [`WORKER_FINALIZE_PHASE_SECS`].
4128    ///   4. OTel telemetry flush: [`OTEL_FLUSH_SECS`] when the `otel`
4129    ///      feature is on (which it is in the default `--features full`
4130    ///      container image the shipped configs assume).
4131    ///   5. Pre-drain wall time: `shutdown_ready_drain_s` counts
4132    ///      against the grace period BEFORE the drain starts (see the
4133    ///      field's own doc comment), and a K8s `preStop` hook counts
4134    ///      against the grace period BEFORE SIGTERM is even delivered.
4135    fn assert_total_shutdown_fits(
4136        label: &str,
4137        config: &HarnessConfig,
4138        grace_seconds: u64,
4139        pre_signal_seconds: u64,
4140    ) {
4141        let drain = config.effective_drain_timeout().as_secs();
4142        let total = drain
4143            .saturating_add(WORKER_FINALIZE_PHASE_SECS)
4144            .saturating_add(WORKER_FINALIZE_PHASE_SECS)
4145            .saturating_add(OTEL_FLUSH_SECS)
4146            .saturating_add(config.shutdown_ready_drain_s)
4147            .saturating_add(pre_signal_seconds);
4148        assert!(
4149            total <= grace_seconds,
4150            "{label}: worst-case shutdown budget {total}s (drain {drain}s + \
4151             worker {WORKER_FINALIZE_PHASE_SECS}s + finalize {WORKER_FINALIZE_PHASE_SECS}s + \
4152             OTel {OTEL_FLUSH_SECS}s + shutdown_ready_drain_s {}s + pre-signal {}s) \
4153             exceeds the grace period {grace_seconds}s — \
4154             kubelet/dockerd will SIGKILL mid-finalize and drop receipts on \
4155             rolling deploy. Either lower shutdown_drain_timeout_s / \
4156             shutdown_ready_drain_s / the preStop sleep in the config or raise \
4157             the grace period in the deployment manifest.",
4158            config.shutdown_ready_drain_s,
4159            pre_signal_seconds,
4160        );
4161    }
4162
4163    fn extract_yaml_int(yaml: &str, key: &str) -> Option<u64> {
4164        for line in yaml.lines() {
4165            let trimmed = line.trim_start();
4166            if let Some(rest) = trimmed.strip_prefix(key) {
4167                return rest.trim().parse().ok();
4168            }
4169        }
4170        None
4171    }
4172
4173    /// Parses the shipped K8s manifest's `preStop.exec.command`
4174    /// `sleep <n>` argument, accepting the three shapes YAML idiomatically
4175    /// admits so a cosmetic reformat can't defeat the pin:
4176    ///   1. inline flow list, double-quoted: `command: ["sh", "-c", "sleep 5"]`
4177    ///   2. inline flow list, single-quoted: `command: ['sh', '-c', 'sleep 5']`
4178    ///   3. block-scalar list — used elsewhere in the same manifest:
4179    ///      ```yaml
4180    ///      command:
4181    ///        - sh
4182    ///        - -c
4183    ///        - sleep 5
4184    ///      ```
4185    /// Returns `None` when there is no preStop hook at all (no `preStop:`
4186    /// key). Returns `None` when a preStop DOES exist but the shape is
4187    /// unrecognized — the caller must panic on that so the drift is
4188    /// caught at build time, not papered over with `unwrap_or(0)`.
4189    fn extract_prestop_sleep_seconds(yaml: &str) -> Option<u64> {
4190        // Locate the FIRST non-comment `preStop:` occurrence. A future
4191        // manifest edit that adds `# don't use preStop:` above the real
4192        // hook would otherwise anchor our scope to the comment and
4193        // trawl the rest of the file for any `sleep`.
4194        let (prestop_line_idx, prestop_indent) = yaml.lines().enumerate().find_map(|(i, line)| {
4195            let trimmed = line.trim_start();
4196            if trimmed.starts_with('#') {
4197                return None;
4198            }
4199            if trimmed.starts_with("preStop:") {
4200                Some((i, line.len() - trimmed.len()))
4201            } else {
4202                None
4203            }
4204        })?;
4205        // Scope: from the preStop line to the FIRST line whose indent
4206        // is ≤ the preStop line's indent (that line starts a sibling
4207        // key, ending the preStop block). Blank lines and comments
4208        // don't end the block. The previous heuristic (`\nresources:`)
4209        // never fired in real manifests where `resources:` is
4210        // indented, so the parser trawled the whole rest of the file
4211        // and returned `sleep` values from unrelated containers /
4212        // probes / sidecars. Verified by the R8 review's mutation
4213        // cases: block-form preStop `sleep 60` + downstream container
4214        // `command: ["sh","-c","sleep 3"]` used to return 3, not 60.
4215        let mut scope = String::new();
4216        for (i, line) in yaml.lines().enumerate().skip(prestop_line_idx) {
4217            if i == prestop_line_idx {
4218                scope.push_str(line);
4219                scope.push('\n');
4220                continue;
4221            }
4222            let trimmed = line.trim_start();
4223            if trimmed.is_empty() || trimmed.starts_with('#') {
4224                scope.push_str(line);
4225                scope.push('\n');
4226                continue;
4227            }
4228            let indent = line.len() - trimmed.len();
4229            if indent <= prestop_indent {
4230                break;
4231            }
4232            scope.push_str(line);
4233            scope.push('\n');
4234        }
4235        // Shape 1 & 2: same-line inline list. Look for either
4236        // "sleep <n>" or 'sleep <n>' anywhere in the scoped block.
4237        for quote in ['"', '\''] {
4238            let marker = format!("{quote}sleep ");
4239            if let Some(hit) = scope.find(&marker) {
4240                let rest = &scope[hit + marker.len()..];
4241                let n: String = rest.chars().take_while(char::is_ascii_digit).collect();
4242                if let Ok(seconds) = n.parse() {
4243                    return Some(seconds);
4244                }
4245            }
4246        }
4247        // Shape 3: block-scalar list. A line whose non-indent content
4248        // is `- sleep <n>`. YAML also allows `- sh` / `- -c` / `- sleep 5`
4249        // on separate lines; we only need the sleep line.
4250        for line in scope.lines() {
4251            let stripped = line.trim_start().trim_start_matches('-').trim();
4252            if let Some(rest) = stripped.strip_prefix("sleep ") {
4253                let n: String = rest.chars().take_while(char::is_ascii_digit).collect();
4254                if let Ok(seconds) = n.parse() {
4255                    return Some(seconds);
4256                }
4257            }
4258        }
4259        None
4260    }
4261
4262    /// Caller-side companion for [`extract_prestop_sleep_seconds`]: a
4263    /// shipped manifest with a `preStop:` hook MUST have a parseable
4264    /// sleep, or the pin cannot compute the grace budget accurately
4265    /// and silently green-lights a hook whose wall time it ignores.
4266    /// The R7 review agent caught this exact class: a YAML block-form
4267    /// reformat + `sleep 60` bump pushed total shutdown to 190 s
4268    /// against the 150 s grace, and the previous `.unwrap_or(0)` let
4269    /// the test pass.
4270    fn require_prestop_sleep_seconds(yaml: &str) -> u64 {
4271        // Only count a REAL preStop line (`preStop:` at the start of a
4272        // non-comment line), not the substring inside a comment like
4273        // `# don't use preStop:`. Same class of bug the scope-cut
4274        // rewrite fixes below.
4275        let has_prestop = yaml.lines().any(|line| {
4276            let trimmed = line.trim_start();
4277            !trimmed.starts_with('#') && trimmed.starts_with("preStop:")
4278        });
4279        if !has_prestop {
4280            return 0;
4281        }
4282        extract_prestop_sleep_seconds(yaml).expect(
4283            "shipped K8s manifest has a preStop hook but extract_prestop_sleep_seconds \
4284             could not parse its sleep duration — either update the parser to handle \
4285             the new shape (inline vs block YAML, quoting style), or if preStop no \
4286             longer uses `sleep`, delete this test and its helper. Silently returning \
4287             0 would defeat the pin's own purpose: catching a preStop-time bump that \
4288             pushes total shutdown past the grace period.",
4289        )
4290    }
4291
4292    /// Direct regression tests for the parser — pinning the exact
4293    /// mutation cases the R8 review agent proved defeated the previous
4294    /// scope-cut heuristic. If these ever regress, the shipped
4295    /// K8s pin above no longer measures what its comment claims.
4296    #[test]
4297    fn extract_prestop_scope_ignores_downstream_sleep_after_the_block() {
4298        // Block-form preStop `sleep 60` in one container followed by
4299        // an unrelated inline container command with `"sleep 3"` in a
4300        // real sibling later in the YAML. Previous version returned
4301        // 3 (first inline match anywhere after preStop:), silently
4302        // making the pin under-count preStop time by 57 s.
4303        let yaml = "\
4304apiVersion: apps/v1
4305kind: Deployment
4306spec:
4307  template:
4308    spec:
4309      containers:
4310      - name: main
4311        lifecycle:
4312          preStop:
4313            exec:
4314              command:
4315                - sh
4316                - -c
4317                - sleep 60
4318        resources:
4319          limits:
4320            cpu: \"2\"
4321      - name: sidecar
4322        command: [\"sh\", \"-c\", \"sleep 3\"]
4323";
4324        assert_eq!(
4325            extract_prestop_sleep_seconds(yaml),
4326            Some(60),
4327            "the parser must scope to the preStop block and ignore \
4328             downstream containers' inline sleep commands"
4329        );
4330    }
4331
4332    #[test]
4333    fn extract_prestop_scope_ignores_downstream_probe_sleep() {
4334        // Inline preStop `sleep 3` next to a downstream livenessProbe
4335        // exec `command: ["sh", "-c", "sleep 300"]`. The key
4336        // differentiator is quote-style ordering: Shape-1 scanning
4337        // walks `['"', '\'']` and returns on the first hit, so a
4338        // single-quoted preStop + double-quoted probe forces the
4339        // old (unscoped) parser to hit the probe's `"sleep 300"`
4340        // FIRST — returning 300 → pin FALSELY reported "grace
4341        // exceeded" for a config that was actually fine. With the
4342        // indent-scoped scan, only the preStop's `'sleep 3'` is in
4343        // range and the answer is 3. Reviewer note: an earlier
4344        // version of this test used matching double quotes for both
4345        // hooks, which the OLD parser also happened to return 3 for
4346        // (double-quote scan hits the preStop's `"sleep 3"` first),
4347        // making the test hollow — it asserted the same value both
4348        // implementations produced.
4349        let yaml = "\
4350spec:
4351  containers:
4352  - name: main
4353    lifecycle:
4354      preStop:
4355        exec:
4356          command: ['sh', '-c', 'sleep 3']
4357    livenessProbe:
4358      exec:
4359        command: [\"sh\", \"-c\", \"sleep 300\"]
4360";
4361        assert_eq!(extract_prestop_sleep_seconds(yaml), Some(3));
4362    }
4363
4364    #[test]
4365    fn require_prestop_skips_commented_out_prestop_line() {
4366        // A YAML comment mentioning preStop must not fool the parser
4367        // into thinking a hook exists — nor into returning an
4368        // unrelated sleep from a downstream probe.
4369        let yaml = "\
4370spec:
4371  containers:
4372  - name: main
4373    # NOTE: do not add preStop: to this container, see runbook.
4374    livenessProbe:
4375      exec:
4376        command: [\"sh\", \"-c\", \"sleep 90\"]
4377";
4378        assert_eq!(
4379            require_prestop_sleep_seconds(yaml),
4380            0,
4381            "a comment mentioning preStop: is NOT a real hook; require_prestop \
4382             must return 0, not the downstream probe's sleep"
4383        );
4384    }
4385
4386    /// Parses `stop_grace_period: 180s` (compose accepts a `s`/`m`/`h`
4387    /// suffix). We only ship an `s` suffix, so a bare int and `<n>s`
4388    /// are the two shapes to accept.
4389    fn extract_yaml_grace_seconds(yaml: &str, key: &str) -> Option<u64> {
4390        for line in yaml.lines() {
4391            let trimmed = line.trim_start();
4392            if let Some(rest) = trimmed.strip_prefix(key) {
4393                let raw = rest.trim().trim_end_matches('s');
4394                return raw.parse().ok();
4395            }
4396        }
4397        None
4398    }
4399
4400    /// deploy/systemd/proofvisor.service + the `pvctl init` default
4401    /// config shape (which writes neither `upstream_read_timeout_s` nor
4402    /// `shutdown_drain_timeout_s`, so the config-derived drain phase is
4403    /// max(30, 60+5) = 65 s). The unit shipped 125 s against a 130 s
4404    /// worst case for years because its comment assumed a 30 s drain —
4405    /// `systemctl restart` SIGKILLed mid-finalize on any deploy whose
4406    /// drain actually ran long. Same class as the compose/k8s pins
4407    /// above; systemd was the one unmanaged sibling.
4408    #[test]
4409    fn shipped_systemd_unit_stop_timeout_fits_default_shutdown() {
4410        let unit = include_str!("../../../deploy/systemd/proofvisor.service");
4411        let stop_timeout = unit
4412            .lines()
4413            .find_map(|line| line.trim().strip_prefix("TimeoutStopSec="))
4414            .and_then(|raw| raw.trim().trim_end_matches('s').parse::<u64>().ok())
4415            .expect("unit file must set TimeoutStopSec explicitly");
4416        // Default-shaped config: only the keys `pvctl init` always writes.
4417        let config =
4418            HarnessConfig::from_toml("config_version = 1\nupstream_url = \"https://api.openai.com\"")
4419                .unwrap();
4420        assert_total_shutdown_fits(
4421            "deploy/systemd/proofvisor.service / pvctl-init defaults",
4422            &config,
4423            stop_timeout,
4424            // systemd has no preStop equivalent; SIGTERM is delivered
4425            // immediately at `systemctl stop`.
4426            0,
4427        );
4428    }
4429
4430    /// `tool_upstream_url = ""` used to pass validation while runtime
4431    /// routing gates tool forwarding on `is_some()` — the empty string
4432    /// silently enabled the tool-upstream branches and only failed at the
4433    /// first request with a reqwest URL error. Reject it at startup.
4434    #[test]
4435    fn empty_tool_upstream_url_is_rejected() {
4436        let err =
4437            HarnessConfig::from_toml("upstream_url = \"https://api\"\ntool_upstream_url = \"\"").unwrap_err();
4438        assert!(err.contains("tool_upstream_url"), "{err}");
4439        assert!(err.contains("omit"), "should point at omitting the field: {err}");
4440        assert!(HarnessConfig::from_toml(
4441            "upstream_url = \"https://api\"\ntool_upstream_url = \"http://tools/mcp\"",
4442        )
4443        .is_ok());
4444    }
4445
4446    /// `payout_field = ""` silently disabled the payout cap: the empty
4447    /// string matches no argument key, `extract_payout_micros` returned
4448    /// 0, and `ActionBudget::try_tool_call` skipped the payout dimension
4449    /// entirely — including the fail-closed "unbounded payout must
4450    /// never spend" refusal. Every peer field in the same class
4451    /// (atif_spool_dir, bridge_data_dir, upstream_auth_header, …) is
4452    /// checked non-empty; this was the outlier. Refuse at startup.
4453    /// Whitespace, control, non-ASCII, and invisible-format (BOM,
4454    /// zero-width space) variants are the same class — extremely
4455    /// unlikely to match a real tool's JSON key and almost certainly
4456    /// a paste typo from a rich-editor or Windows/UTF-8 signature.
4457    /// `is_ascii_graphic()` covers all of these in one check.
4458    #[test]
4459    fn empty_or_whitespace_payout_field_is_rejected() {
4460        for hostile in [
4461            "",
4462            " ",
4463            "  ",
4464            "\t",
4465            " amount_usd",
4466            "amount_usd ",
4467            // Invisible-format class — the exact one browser paste
4468            // injects and that `trim()` misses because Unicode's
4469            // White_Space property does not cover these.
4470            "\u{FEFF}amount_usd", // BOM at start (UTF-8 signature)
4471            "amount_usd\u{FEFF}", // BOM at end
4472            "\u{200B}amount_usd", // zero-width space
4473            "amount\u{200B}_usd", // zero-width space in the middle
4474            "\u{2060}amount_usd", // word-joiner
4475            "amount_usd\u{00A0}", // NBSP (a Unicode whitespace)
4476            // Non-ASCII: smart quotes / accents / emoji / RTL text.
4477            "amount_usd\u{201D}", // right double-quote
4478            "amóunt_usd",         // accented Latin
4479        ] {
4480            // Interpolate the RAW hostile bytes into the TOML — not
4481            // `{:?}` Debug format, which emits Rust's `\u{feff}`
4482            // escape that TOML doesn't accept (only `\uXXXX` /
4483            // `\UXXXXXXXX`, no braces). The prior version parse-
4484            // failed at the toml crate for every invisible-format
4485            // case, so the payout_field check never actually ran on
4486            // the class the fix targets: the R12 review agent proved
4487            // that reverting the check to R11's `trim()`-based
4488            // predicate still passed 6 of the 8 test cases.
4489            //
4490            // The `[` / `]` / `{` / `}` / `#` in `hostile` cannot
4491            // appear here (all our fixtures are payout-key shapes),
4492            // and TOML basic strings interpret embedded `\` and `"`
4493            // — but our fixtures contain neither. Raw interpolation
4494            // is therefore safe AND correct for THIS test.
4495            let toml = format!("upstream_url = \"https://api.openai.com\"\npayout_field = \"{hostile}\"\n");
4496            let err = match HarnessConfig::from_toml(&toml) {
4497                Err(e) => e,
4498                Ok(_) => panic!("must refuse payout_field = {hostile:?}"),
4499            };
4500            assert!(err.contains("payout_field"), "{hostile:?} => {err}");
4501        }
4502        // The default (omitted key) is fine.
4503        assert!(HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).is_ok());
4504        // An explicit clean name is fine.
4505        assert!(HarnessConfig::from_toml(
4506            r#"upstream_url = "https://api.openai.com"
4507               payout_field = "amount_usd""#,
4508        )
4509        .is_ok());
4510    }
4511
4512    /// The hostless preflight `upstream_url` gets must cover
4513    /// `tool_upstream_url` too: `tool_upstream_url = "http://"` passed
4514    /// validation, booted, then failed EVERY tool call at request-build
4515    /// time — with claimed executions churning TOOL_OUTCOME_UNCERTAIN.
4516    #[test]
4517    fn hostless_tool_upstream_url_is_rejected() {
4518        for url in ["http://", "https:///mcp", "http://?x=1"] {
4519            let err = HarnessConfig::from_toml(&format!(
4520                "upstream_url = \"https://api\"\ntool_upstream_url = \"{url}\""
4521            ))
4522            .unwrap_err();
4523            assert!(
4524                err.contains("tool_upstream_url") && err.contains("no host"),
4525                "{url}: {err}"
4526            );
4527        }
4528    }
4529
4530    /// Request paths are CONCATENATED onto `upstream_url`
4531    /// (`format!("{base}/v1/chat/completions")`), so a base carrying a
4532    /// query or fragment silently swallows the path: `http://h/#x` +
4533    /// `/v1/...` targets `/` with everything after `#` treated as a
4534    /// fragment. That misconfiguration used to validate + boot and
4535    /// only misbehave per-request. `tool_upstream_url` stays exempt:
4536    /// it is posted WHOLE, so a query there is legitimate.
4537    #[test]
4538    fn upstream_url_rejects_query_and_fragment() {
4539        for url in ["http://127.0.0.1:9000/#oops", "https://api.example.com/?tenant=1"] {
4540            let err = HarnessConfig::from_toml(&format!("upstream_url = \"{url}\"")).unwrap_err();
4541            assert!(
4542                err.contains("upstream_url") && err.contains("query or fragment"),
4543                "{url}: {err}"
4544            );
4545        }
4546        // A query on tool_upstream_url (posted whole) stays legal.
4547        HarnessConfig::from_toml(
4548            "upstream_url = \"https://api\"\ntool_upstream_url = \"http://tools/mcp?fmt=json\"",
4549        )
4550        .unwrap();
4551    }
4552
4553    /// `require_identity = true` gates the proxy API but NOT the
4554    /// dashboard routes — a wildcard bind with the dashboard on hands
4555    /// any peer the session list and complete receipts. Same explicit
4556    /// `allow_wildcard_bind` acknowledgment as the anonymous-proxy
4557    /// guard; loopback binds and dashboard-off stay untouched.
4558    #[test]
4559    fn wildcard_bind_with_dashboard_requires_explicit_acknowledgment() {
4560        let err = HarnessConfig::from_toml(
4561            r#"upstream_url = "https://api"
4562               listen = "0.0.0.0:8484"
4563               require_identity = true
4564               identity_hmac_secret_file = "/etc/pv/hmac"
4565               dashboard_enabled = true"#,
4566        )
4567        .unwrap_err();
4568        assert!(
4569            err.contains("dashboard") && err.contains("every interface"),
4570            "wildcard + dashboard must refuse: {err}"
4571        );
4572        // Explicit acknowledgment or dashboard-off both boot.
4573        HarnessConfig::from_toml(
4574            r#"upstream_url = "https://api"
4575               listen = "0.0.0.0:8484"
4576               require_identity = true
4577               identity_hmac_secret_file = "/etc/pv/hmac"
4578               dashboard_enabled = true
4579               allow_wildcard_bind = true"#,
4580        )
4581        .unwrap();
4582        HarnessConfig::from_toml(
4583            r#"upstream_url = "https://api"
4584               listen = "0.0.0.0:8484"
4585               require_identity = true
4586               identity_hmac_secret_file = "/etc/pv/hmac"
4587               dashboard_enabled = false"#,
4588        )
4589        .unwrap();
4590    }
4591
4592    /// A seconds interval > 1 day is almost certainly a unit-conversion
4593    /// error (someone thought the field was in milliseconds).
4594    #[test]
4595    fn seconds_intervals_reject_absurdly_large_values() {
4596        let err = HarnessConfig::from_toml(&format!(
4597            "upstream_url = \"https://api\"\nsession_idle_close_s = {}",
4598            MAX_SECONDS_INTERVAL + 1
4599        ))
4600        .unwrap_err();
4601        assert!(
4602            err.contains("session_idle_close_s"),
4603            "err should name the offending field: {err}"
4604        );
4605        assert!(err.contains("milliseconds"), "hint should mention ms: {err}");
4606    }
4607
4608    /// `upstream_read_timeout_s = 0` would make every upstream request
4609    /// time out before its first byte; reject it with a pointer at the
4610    /// omit-for-default posture.
4611    #[test]
4612    fn upstream_read_timeout_rejects_zero() {
4613        let err = HarnessConfig::from_toml(
4614            r#"upstream_url = "https://api"
4615               upstream_read_timeout_s = 0"#,
4616        )
4617        .unwrap_err();
4618        assert!(
4619            err.contains("upstream_read_timeout_s"),
4620            "err should name the offending field: {err}"
4621        );
4622        assert!(err.contains("omit"), "err should point at the default: {err}");
4623        // Any positive value under the 1-day cap remains valid.
4624        assert!(HarnessConfig::from_toml(
4625            r#"upstream_url = "https://api"
4626               upstream_read_timeout_s = 1"#,
4627        )
4628        .is_ok());
4629    }
4630
4631    #[test]
4632    fn mcp_request_timeout_is_positive_and_bounded() {
4633        let default = HarnessConfig::from_toml(r#"upstream_url = "https://api""#).unwrap();
4634        assert_eq!(
4635            default
4636                .mcp_request_timeout_s
4637                .unwrap_or(DEFAULT_MCP_REQUEST_TIMEOUT_S),
4638            60
4639        );
4640        for invalid in [0, MAX_SECONDS_INTERVAL + 1] {
4641            let error = HarnessConfig::from_toml(&format!(
4642                "upstream_url = \"https://api\"\nmcp_request_timeout_s = {invalid}"
4643            ))
4644            .unwrap_err();
4645            assert!(error.contains("mcp_request_timeout_s"), "{error}");
4646        }
4647        for valid in [1, DEFAULT_MCP_REQUEST_TIMEOUT_S, MAX_SECONDS_INTERVAL] {
4648            let config = HarnessConfig::from_toml(&format!(
4649                "upstream_url = \"https://api\"\nmcp_request_timeout_s = {valid}"
4650            ))
4651            .unwrap();
4652            assert_eq!(config.mcp_request_timeout_s, Some(valid));
4653        }
4654    }
4655
4656    /// Refuse `enforce_identity_scopes = true` while
4657    /// `require_identity = false`. The combo silently falls through
4658    /// to the anonymous identity on unauthenticated requests,
4659    /// making the scope config a fig leaf.
4660    #[test]
4661    fn round_30_f1_scope_enforcement_requires_identity_requirement() {
4662        let err = HarnessConfig::from_toml(
4663            r#"upstream_url = "https://api.openai.com"
4664               require_identity = false
4665               enforce_identity_scopes = true"#,
4666        )
4667        .unwrap_err();
4668        assert!(
4669            err.contains("enforce_identity_scopes"),
4670            "err should name the flag: {err}"
4671        );
4672        assert!(
4673            err.contains("require_identity"),
4674            "err should name the correlate: {err}"
4675        );
4676        // Both `false` = clean dev posture, still passes.
4677        assert!(HarnessConfig::from_toml(
4678            r#"upstream_url = "https://api.openai.com"
4679               require_identity = false
4680               enforce_identity_scopes = false"#,
4681        )
4682        .is_ok());
4683    }
4684
4685    /// Shutdown derives from both network timeout settings. Read-idle
4686    /// padding does not promise that a whole chat stream can finish.
4687    #[test]
4688    fn drain_timeout_derives_from_upstream_read_timeout() {
4689        let base = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
4690        assert_eq!(base.effective_drain_timeout().as_secs(), 65);
4691        let mut derived = base.clone();
4692        derived.upstream_read_timeout_s = Some(300);
4693        assert_eq!(derived.effective_drain_timeout().as_secs(), 305);
4694        derived.mcp_request_timeout_s = Some(600);
4695        assert_eq!(derived.effective_drain_timeout().as_secs(), 605);
4696        // A short read-idle setting must not shrink the MCP deadline.
4697        let mut short = base.clone();
4698        short.upstream_read_timeout_s = Some(10);
4699        assert_eq!(short.effective_drain_timeout().as_secs(), 65);
4700        short.mcp_request_timeout_s = Some(10);
4701        assert_eq!(short.effective_drain_timeout().as_secs(), 30);
4702        // An operator-supplied shutdown limit still takes precedence.
4703        let mut explicit = derived;
4704        explicit.shutdown_drain_timeout_s = Some(110);
4705        assert_eq!(explicit.effective_drain_timeout().as_secs(), 110);
4706        let mut zero = base;
4707        zero.shutdown_drain_timeout_s = Some(0);
4708        assert!(zero.validate().unwrap_err().contains("shutdown_drain_timeout_s"));
4709    }
4710
4711    /// Refuse a wildcard bind (`0.0.0.0` / `[::]`) when identity is off
4712    /// unless an operator has explicitly acknowledged that an outer network
4713    /// layer controls exposure via `allow_wildcard_bind = true`. This
4714    /// prevents an unauthenticated proxy from silently reaching a
4715    /// developer's corporate LAN because a checked-in example config
4716    /// happened to bind wildcards.
4717    #[test]
4718    fn wildcard_bind_without_identity_is_refused_unless_explicitly_allowed() {
4719        // 0.0.0.0 with require_identity=false and no opt-in → refuse.
4720        let err = HarnessConfig::from_toml(
4721            r#"upstream_url = "https://api.openai.com"
4722               listen = "0.0.0.0:8484"
4723               require_identity = false"#,
4724        )
4725        .unwrap_err();
4726        assert!(err.contains("every interface"), "err should name the risk: {err}");
4727        assert!(
4728            err.contains("allow_wildcard_bind"),
4729            "err should name the escape hatch: {err}"
4730        );
4731
4732        // IPv6 wildcard likewise refused.
4733        let err = HarnessConfig::from_toml(
4734            r#"upstream_url = "https://api.openai.com"
4735               listen = "[::]:8484"
4736               require_identity = false"#,
4737        )
4738        .unwrap_err();
4739        assert!(err.contains("every interface"), "IPv6 wildcard: {err}");
4740
4741        // Non-canonical spellings of the unspecified address are the
4742        // same all-interfaces bind and must be refused too — the prior
4743        // literal string match (`"0.0.0.0" | "::"`) waved these through.
4744        for listen in [
4745            "[::0]:8484",
4746            "[0:0:0:0:0:0:0:0]:8484",
4747            "[0000::]:8484",
4748            // v4-mapped zero: parses as a non-unspecified Ipv6Addr but
4749            // binds INADDR_ANY — needs to_canonical() to classify.
4750            "[::ffff:0.0.0.0]:8484",
4751            "[::ffff:0:0]:8484",
4752        ] {
4753            let err = HarnessConfig::from_toml(&format!(
4754                r#"upstream_url = "https://api.openai.com"
4755                   listen = "{listen}"
4756                   require_identity = false"#,
4757            ))
4758            .unwrap_err();
4759            assert!(
4760                err.contains("every interface"),
4761                "non-canonical wildcard {listen}: {err}"
4762            );
4763        }
4764
4765        // Loopback remains legal.
4766        HarnessConfig::from_toml(
4767            r#"upstream_url = "https://api.openai.com"
4768               listen = "127.0.0.1:8484"
4769               require_identity = false"#,
4770        )
4771        .unwrap()
4772        .validate()
4773        .unwrap();
4774
4775        // Explicit opt-in (container / service-mesh deployments) passes.
4776        HarnessConfig::from_toml(
4777            r#"upstream_url = "https://api.openai.com"
4778               listen = "0.0.0.0:8484"
4779               require_identity = false
4780               allow_wildcard_bind = true"#,
4781        )
4782        .unwrap()
4783        .validate()
4784        .unwrap();
4785
4786        // require_identity=true with a JWKS / HMAC source likewise
4787        // passes — headless (the default-on dashboard has no identity
4788        // gate, so a wildcard bind additionally requires
4789        // dashboard_enabled = false or the allow_wildcard_bind
4790        // acknowledgment; see
4791        // wildcard_bind_with_dashboard_requires_explicit_acknowledgment).
4792        HarnessConfig::from_toml(
4793            r#"upstream_url = "https://api.openai.com"
4794               listen = "0.0.0.0:8484"
4795               require_identity = true
4796               dashboard_enabled = false
4797               identity_jwks_url = "https://idp/keys.json""#,
4798        )
4799        .unwrap()
4800        .validate()
4801        .unwrap();
4802    }
4803
4804    /// Refuse URL fields that omit the scheme or use a
4805    /// scheme the client library will not accept. Preflight beats a
4806    /// runtime failure after the process is already serving traffic.
4807    #[test]
4808    fn round_30_f2_url_scheme_allowlist_enforced() {
4809        // JWKS URL: missing scheme.
4810        let err = HarnessConfig::from_toml(
4811            r#"upstream_url = "https://api"
4812               identity_jwks_url = "auth.internal/jwks""#,
4813        )
4814        .unwrap_err();
4815        assert!(err.contains("identity_jwks_url"), "{err}");
4816        assert!(err.contains("http://") || err.contains("https://"), "{err}");
4817        // JWKS URL: file scheme is a config-injection surface.
4818        let err = HarnessConfig::from_toml(
4819            r#"upstream_url = "https://api"
4820               identity_jwks_url = "file:///etc/passwd""#,
4821        )
4822        .unwrap_err();
4823        assert!(err.contains("identity_jwks_url"), "{err}");
4824        // Redis state_endpoint: bad scheme.
4825        let err = HarnessConfig::from_toml(
4826            r#"upstream_url = "https://api"
4827               state_backend = "redis"
4828               state_endpoint = "http://cache""#,
4829        )
4830        .unwrap_err();
4831        assert!(err.contains("state_endpoint"), "{err}");
4832        // Redis state_endpoint cluster list — one bad
4833        // member must fail validation even if the FIRST member has
4834        // an allowed scheme (the prior prefix-on-whole-string check
4835        // passed on any list beginning with `redis://`).
4836        let err = HarnessConfig::from_toml(
4837            r#"upstream_url = "https://api"
4838               state_backend = "redis"
4839               state_endpoint = "redis://a:6379,http://b:6379""#,
4840        )
4841        .unwrap_err();
4842        assert!(err.contains("state_endpoint"), "{err}");
4843        // Valid cluster list of two Redis URLs passes.
4844        HarnessConfig::from_toml(
4845            r#"upstream_url = "https://api"
4846               state_backend = "redis"
4847               state_endpoint = "rediss://a:6379,rediss://b:6380""#,
4848        )
4849        .unwrap();
4850        for endpoint in [
4851            "redis://a:6379,rediss://b:6380",
4852            "rediss://a:6379/#insecure",
4853            "redis://a:6379,",
4854            "",
4855            "http://user:fixture-secret@cache:6379",
4856        ] {
4857            let err = HarnessConfig::from_toml(&format!(
4858                "upstream_url = \"https://api\"\nstate_backend = \"redis\"\nstate_endpoint = {endpoint:?}"
4859            ))
4860            .unwrap_err();
4861            assert!(err.contains("state_endpoint"), "{err}");
4862            assert!(
4863                !err.contains("fixture-secret"),
4864                "configuration errors must not expose credentials"
4865            );
4866        }
4867        // redis+unix: is a legitimate Unix-socket form
4868        // the redis crate accepts; validate must not reject it.
4869        HarnessConfig::from_toml(
4870            r#"upstream_url = "https://api"
4871               state_backend = "redis"
4872               state_endpoint = "redis+unix:/tmp/redis.sock""#,
4873        )
4874        .unwrap();
4875        // max_request_bytes = 0 is a silent-breakage
4876        // config (DefaultBodyLimit::max(0) rejects every non-empty
4877        // POST body); validate must refuse it.
4878        let err = HarnessConfig::from_toml(
4879            r#"upstream_url = "https://api"
4880               max_request_bytes = 0"#,
4881        )
4882        .unwrap_err();
4883        assert!(err.contains("max_request_bytes"), "{err}");
4884        // NATS bridge_endpoint: bad scheme.
4885        let err = HarnessConfig::from_toml(
4886            r#"upstream_url = "https://api"
4887               bridge_backend = "nats"
4888               bridge_endpoint = "http://bus""#,
4889        )
4890        .unwrap_err();
4891        assert!(err.contains("bridge_endpoint"), "{err}");
4892        // Qdrant: missing scheme.
4893        let err = HarnessConfig::from_toml(
4894            r#"upstream_url = "https://api"
4895               vector_backend = "qdrant"
4896               qdrant_url = "vectors.internal:6333""#,
4897        )
4898        .unwrap_err();
4899        assert!(err.contains("qdrant_url"), "{err}");
4900        // Legit values pass.
4901        assert!(HarnessConfig::from_toml(
4902            r#"upstream_url = "https://api"
4903               identity_jwks_url = "https://auth.internal/jwks"
4904               state_backend = "redis"
4905               state_endpoint = "redis://cache:6379"
4906               bridge_backend = "nats"
4907               bridge_endpoint = "nats://bus:4222"
4908               vector_backend = "qdrant"
4909               qdrant_url = "https://vectors.internal:6333""#,
4910        )
4911        .is_ok());
4912    }
4913
4914    /// Empty `atif_spool_dir` / `bridge_data_dir` are
4915    /// rejected. Without the check they default to `Path::new("")`,
4916    /// making every spool op write to the process CWD instead of the
4917    /// expected volume.
4918    #[test]
4919    fn round_31_f1_local_fs_paths_empty_rejected() {
4920        let err = HarnessConfig::from_toml(
4921            r#"upstream_url = "https://api"
4922               atif_spool_dir = """#,
4923        )
4924        .unwrap_err();
4925        assert!(err.contains("atif_spool_dir"), "{err}");
4926        let err = HarnessConfig::from_toml(
4927            r#"upstream_url = "https://api"
4928               bridge_data_dir = """#,
4929        )
4930        .unwrap_err();
4931        assert!(err.contains("bridge_data_dir"), "{err}");
4932    }
4933
4934    /// Identity scope names must be visible ASCII, no
4935    /// whitespace, no empty strings. Some IdPs tokenize an empty
4936    /// "scope" claim into an empty string; without this check the
4937    /// gate becomes `scopes.contains("")` and any token satisfies it.
4938    #[test]
4939    fn round_31_f2_scope_names_rejected_empty_or_whitespaced() {
4940        // Empty chat_scope.
4941        let err = HarnessConfig::from_toml(
4942            r#"upstream_url = "https://api"
4943               require_identity = true
4944               identity_hmac_secret_file = "/tmp/secret"
4945               enforce_identity_scopes = true
4946               chat_scope = """#,
4947        )
4948        .unwrap_err();
4949        assert!(err.contains("chat_scope"), "{err}");
4950        // Whitespace in scope.
4951        let err = HarnessConfig::from_toml(
4952            r#"upstream_url = "https://api"
4953               require_identity = true
4954               identity_hmac_secret_file = "/tmp/secret"
4955               enforce_identity_scopes = true
4956               chat_scope = "chat write""#,
4957        )
4958        .unwrap_err();
4959        assert!(err.contains("chat_scope"), "{err}");
4960        // Control byte in scope.
4961        let err = HarnessConfig::from_toml(
4962            r#"upstream_url = "https://api"
4963               require_identity = true
4964               identity_hmac_secret_file = "/tmp/secret"
4965               enforce_identity_scopes = true
4966               session_close_scope = "close\tsession""#,
4967        )
4968        .unwrap_err();
4969        assert!(err.contains("session_close_scope"), "{err}");
4970        // A well-formed scope passes.
4971        assert!(HarnessConfig::from_toml(
4972            r#"upstream_url = "https://api"
4973               require_identity = true
4974               identity_hmac_secret_file = "/tmp/secret"
4975               enforce_identity_scopes = true
4976               chat_scope = "chat:write"
4977               session_close_scope = "session:close"
4978               session_promote_scope = "session:promote""#,
4979        )
4980        .is_ok());
4981    }
4982}
4983
4984// The permissive test constructor lives in its own
4985// FILE so a grep through config.rs finds only production defaults —
4986// `for_tests`'s test values fooled three independent reviewers. A
4987// child module (not a sibling) so it can reach the private
4988// `default_*` helpers without widening their visibility.
4989// Gated OUT of production builds entirely (Action Register item 7):
4990// `test` covers this crate's unit tests; the `test-support` feature is
4991// enabled only by the self dev-dependency, so integration tests and
4992// benches see it while `cargo build`/`--release`/`--features full`
4993// artifacts cannot even name it.
4994#[cfg(any(test, feature = "test-support"))]
4995#[path = "config_testkit.rs"]
4996mod testkit;