Skip to main content

harn_cli/package/
manifest.rs

1use super::errors::PackageError;
2use super::*;
3mod check_config;
4mod connector_module;
5mod provider_setup;
6pub(crate) use check_config::absolutize_check_config_paths;
7pub use check_config::{load_check_config, CheckConfig, PreflightSeverity};
8pub use connector_module::is_declared_connector_module;
9pub use harn_modules::personas::{
10    PersonaAutonomyTier, PersonaManifestEntry, PersonaStageDecl, PersonaStageExit,
11    PersonaValidationError, ResolvedPersonaManifest,
12};
13pub use provider_setup::{
14    connector_service_issues, ConnectorConditionalProfileRequirement,
15    ConnectorConfigurationEnvironmentManifest, ConnectorCredentialEnvironmentManifest,
16    ConnectorEnvironment, ConnectorEvidenceRequirement, ConnectorExternalSpend,
17    ConnectorHealthCheckManifest, ConnectorOperationEffect, ConnectorOperationManifest,
18    ConnectorProtectedProfileManifest, ConnectorReconciliation, ConnectorRecoveryCopy,
19    ConnectorRedactionTarget, ConnectorServiceManifest, ConnectorSetupConfigurationField,
20    ConnectorTestProfile, ProtectedProfileFieldClass, ProviderManifestEntry, ProviderSetupManifest,
21    ResolvedProviderConnectorConfig,
22};
23
24#[derive(Debug, Clone, Deserialize)]
25pub struct Manifest {
26    pub package: Option<PackageInfo>,
27    #[serde(default)]
28    pub dependencies: HashMap<String, Dependency>,
29    #[serde(default)]
30    pub mcp: Vec<McpServerConfig>,
31    #[serde(default)]
32    pub check: CheckConfig,
33    #[serde(default)]
34    pub workspace: WorkspaceConfig,
35    /// `[registry]` table — lightweight package discovery index configuration.
36    /// The CLI also honors `HARN_PACKAGE_REGISTRY` and `--registry` overrides.
37    #[serde(default)]
38    pub registry: PackageRegistryConfig,
39    /// `[skills]` table — per-project skill discovery configuration
40    /// (paths, lookup_order, disable).
41    #[serde(default)]
42    pub skills: SkillsConfig,
43    /// `[[skill.source]]` array-of-tables — declared skill sources
44    /// (filesystem, git, reserved registry).
45    #[serde(default)]
46    pub skill: SkillTables,
47    /// `[capabilities]` section — per-provider-per-model override of
48    /// the shipped capability matrix (`defer_loading`, `tool_search`,
49    /// `prompt_caching`, etc.). Entries under `[[capabilities.provider.<name>]]`
50    /// are prepended to the built-in rules for the same provider so
51    /// early adopters can flag proxied endpoints as supporting tool
52    /// search without waiting for a Harn release. See
53    /// `harn_vm::llm::capabilities` for the rule schema.
54    #[serde(default)]
55    pub capabilities: Option<harn_vm::llm::capabilities::CapabilitiesFile>,
56    /// Stable exported package modules. Keys are the logical import
57    /// suffixes (e.g. `providers/openai`) and values are package-root-
58    /// relative file paths. Consumers import them via `<package>/<key>`.
59    #[serde(default)]
60    pub exports: HashMap<String, String>,
61    /// `[llm]` section — packaged provider definitions, aliases,
62    /// inference rules, tier rules, and model defaults. Uses the same
63    /// schema as `providers.toml`, but merges into the current run
64    /// instead of replacing the global config file.
65    #[serde(default)]
66    pub llm: harn_vm::llm_config::ProvidersConfig,
67    /// `[[hooks]]` array-of-tables — declarative runtime hooks installed
68    /// once per process/thread before execution starts. Matches the
69    /// manifest-extension ABI shape added by `[exports]` / `[llm]`, but
70    /// the handlers themselves live in Harn modules.
71    #[serde(default)]
72    pub hooks: Vec<HookConfig>,
73    /// `[[triggers]]` array-of-tables — declarative event-driven trigger
74    /// registrations that resolve local handlers and predicates from Harn
75    /// modules at load time and preserve remote URI schemes for later
76    /// dispatcher work.
77    #[serde(default)]
78    pub triggers: Vec<TriggerManifestEntry>,
79    /// `[[handoff_routes]]` array-of-tables — declarative handoff route data.
80    /// Route selection stays in Harn stdlib/persona code; the Rust manifest
81    /// loader makes these tenant routes available to that code.
82    #[serde(default)]
83    pub handoff_routes: Vec<harn_vm::HandoffRouteConfig>,
84    /// `[[providers]]` array-of-tables — provider-specific connector
85    /// overrides used by the orchestrator to load either builtin Rust
86    /// connectors or `.harn` modules as connector implementations.
87    #[serde(default)]
88    pub providers: Vec<ProviderManifestEntry>,
89    /// `[[personas]]` array-of-tables — durable, non-executing agent role
90    /// manifests. Personas bind an entry workflow to tools, capabilities,
91    /// autonomy, budgets, receipts, handoffs, evals, and rollout metadata.
92    #[serde(default)]
93    pub personas: Vec<PersonaManifestEntry>,
94    /// `[connector_contract]` table — deterministic package-local fixtures
95    /// consumed by `harn connector check` for pure-Harn connector packages.
96    #[serde(default, alias = "connector-contract")]
97    pub connector_contract: ConnectorContractConfig,
98    /// `[orchestrator]` table — listener-level controls shared by
99    /// manifest-driven ingress surfaces.
100    #[serde(default)]
101    pub orchestrator: OrchestratorConfig,
102    /// `[rules]` table — `sgconfig`-style structural-rule discovery. Lists the
103    /// directories `harn scan` / `harn codemod` load rules from when no
104    /// explicit `--rule`/`--rule-pack` is given.
105    #[serde(default)]
106    pub rules: RulesConfig,
107    /// `[[contributes]]` array-of-tables — host-surface extension
108    /// contributions (editor languages, preview panes, build profiles,
109    /// commands, themes, …). Harn treats `kind` as a host-owned, namespaced
110    /// string and validates only the envelope plus that each contribution's
111    /// declared `scopes` are covered by `[package].permissions`; the host
112    /// (e.g. a host) interprets the kind-specific payload. New contribution
113    /// kinds therefore need no Harn release. This is the editor-layer twin of
114    /// the agent-layer blocks (`[[providers]]`, `[[personas]]`, `[[hooks]]`):
115    /// one signed package may populate any mix of both.
116    #[serde(default)]
117    pub contributes: Vec<ContributionEntry>,
118}
119
120/// A single `[[contributes]]` host-surface contribution.
121///
122/// ```toml
123/// [[contributes]]
124/// kind = "editor.language"          # host-owned namespaced vocabulary
125/// id = "latex"                      # unique within the package
126/// title = "LaTeX"
127/// when = "*.tex"                    # optional activation predicate (host-interpreted)
128/// scopes = ["workspace:read_text"]  # MUST be a subset of [package].permissions
129/// platforms = ["macos", "linux"]    # optional support/parity matrix; empty = all
130/// # kind-specific keys are captured into `config` and interpreted by the host:
131/// languageId = "latex"
132/// extensions = [".tex", ".sty"]
133/// ```
134#[derive(Debug, Clone, Deserialize, Serialize)]
135pub struct ContributionEntry {
136    /// Host-owned, namespaced contribution kind (e.g. `editor.language`,
137    /// `editor.preview`, `build.profile`, `editor.command`, `editor.theme`).
138    /// Harn does not enumerate kinds — new ones need no release; it only
139    /// requires the value be namespaced (`segment(.segment)+`).
140    pub kind: String,
141    /// Stable identifier, unique across the package's contributions.
142    pub id: String,
143    #[serde(default)]
144    pub title: Option<String>,
145    /// Optional activation predicate the host interprets (a glob, a
146    /// `languageId`, or a host-defined expression). Absent = always available.
147    #[serde(default)]
148    pub when: Option<String>,
149    /// Capability scopes this contribution exercises. Every entry MUST be
150    /// declared in `[package].permissions`; validation fails closed otherwise.
151    #[serde(default)]
152    pub scopes: Vec<String>,
153    /// Optional support/parity matrix — which surfaces/platforms this
154    /// contribution targets (e.g. `macos`, `linux`, `windows`, `ide`, `tui`).
155    /// Empty means "all".
156    #[serde(default)]
157    pub platforms: Vec<String>,
158    /// Kind-specific payload, captured verbatim and interpreted by the host.
159    #[serde(flatten)]
160    pub config: BTreeMap<String, toml::Value>,
161}
162
163impl ContributionEntry {
164    /// `true` when `kind` is a non-empty, dot-namespaced identifier such as
165    /// `editor.language`. Single-segment kinds are rejected so third parties
166    /// cannot squat unprefixed names.
167    pub fn has_namespaced_kind(&self) -> bool {
168        let mut segments = 0usize;
169        for segment in self.kind.split('.') {
170            if segment.is_empty()
171                || !segment
172                    .chars()
173                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
174                || !segment.starts_with(|c: char| c.is_ascii_lowercase())
175            {
176                return false;
177            }
178            segments += 1;
179        }
180        segments >= 2
181    }
182}
183
184/// `[rules]` table — project-local structural-rule discovery (#2843).
185///
186/// ```toml
187/// [rules]
188/// ruleDirs = ["rules", "vendor/rules"]
189/// utilDirs = ["rules/util"]
190/// testConfigs = ["rules/tests"]
191/// nativeRuleDirs = ["target/harn-native-rules"]
192/// ```
193///
194/// Paths are resolved relative to the manifest's directory.
195#[derive(Debug, Clone, Default, Deserialize)]
196pub struct RulesConfig {
197    /// Directories of top-level rule `*.toml` files to load.
198    #[serde(default, alias = "rule-dirs", alias = "ruleDirs")]
199    pub rule_dirs: Vec<String>,
200    /// Directories of utility-rule `*.toml` files (referenced via `matches`).
201    #[serde(default, alias = "util-dirs", alias = "utilDirs")]
202    pub util_dirs: Vec<String>,
203    /// Directories holding rule-test fixtures (for `harn rule test`).
204    #[serde(default, alias = "test-configs", alias = "testConfigs")]
205    pub test_configs: Vec<String>,
206    /// Trusted directories of native lint-rule dynamic libraries.
207    #[serde(
208        default,
209        alias = "native-rule-dirs",
210        alias = "nativeRuleDirs",
211        alias = "native_rule_dirs"
212    )]
213    pub native_rule_dirs: Vec<String>,
214}
215
216#[derive(Debug, Clone, Default, Deserialize)]
217pub struct OrchestratorConfig {
218    #[serde(default, alias = "allowed-origins")]
219    pub allowed_origins: Vec<String>,
220    #[serde(default, alias = "max-body-bytes")]
221    pub max_body_bytes: Option<usize>,
222    #[serde(default)]
223    pub budget: OrchestratorBudgetSpec,
224    #[serde(default)]
225    pub drain: OrchestratorDrainConfig,
226    #[serde(default)]
227    pub pumps: OrchestratorPumpConfig,
228}
229
230#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
231pub struct OrchestratorBudgetSpec {
232    #[serde(default)]
233    pub daily_cost_usd: Option<f64>,
234    #[serde(default)]
235    pub hourly_cost_usd: Option<f64>,
236}
237
238#[derive(Debug, Clone, Deserialize)]
239pub struct OrchestratorDrainConfig {
240    #[serde(default = "default_orchestrator_drain_max_items", alias = "max-items")]
241    pub max_items: usize,
242    #[serde(
243        default = "default_orchestrator_drain_deadline_seconds",
244        alias = "deadline-seconds"
245    )]
246    pub deadline_seconds: u64,
247}
248
249impl Default for OrchestratorDrainConfig {
250    fn default() -> Self {
251        Self {
252            max_items: default_orchestrator_drain_max_items(),
253            deadline_seconds: default_orchestrator_drain_deadline_seconds(),
254        }
255    }
256}
257
258pub(crate) fn default_orchestrator_drain_max_items() -> usize {
259    1024
260}
261
262pub(crate) fn default_orchestrator_drain_deadline_seconds() -> u64 {
263    30
264}
265
266#[derive(Debug, Clone, Deserialize)]
267pub struct OrchestratorPumpConfig {
268    #[serde(
269        default = "default_orchestrator_pump_max_outstanding",
270        alias = "max-outstanding"
271    )]
272    pub max_outstanding: usize,
273}
274
275impl Default for OrchestratorPumpConfig {
276    fn default() -> Self {
277        Self {
278            max_outstanding: default_orchestrator_pump_max_outstanding(),
279        }
280    }
281}
282
283pub(crate) fn default_orchestrator_pump_max_outstanding() -> usize {
284    64
285}
286
287#[derive(Debug, Clone, Deserialize)]
288pub struct HookConfig {
289    pub event: harn_vm::orchestration::HookEvent,
290    #[serde(default = "default_hook_pattern")]
291    pub pattern: String,
292    pub handler: String,
293}
294
295pub(crate) fn default_hook_pattern() -> String {
296    "*".to_string()
297}
298
299#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
300pub struct TriggerManifestEntry {
301    pub id: String,
302    #[serde(default)]
303    pub kind: Option<TriggerKind>,
304    #[serde(default)]
305    pub provider: Option<harn_vm::ProviderId>,
306    #[serde(default, alias = "tier")]
307    pub autonomy_tier: harn_vm::AutonomyTier,
308    #[serde(default, rename = "match")]
309    pub match_: Option<TriggerMatchExpr>,
310    #[serde(default)]
311    pub sources: Vec<TriggerSourceManifestEntry>,
312    #[serde(default)]
313    pub when: Option<String>,
314    #[serde(default)]
315    pub when_budget: Option<TriggerWhenBudgetSpec>,
316    pub handler: String,
317    #[serde(default)]
318    pub dedupe_key: Option<String>,
319    #[serde(default)]
320    pub retry: TriggerRetrySpec,
321    #[serde(default)]
322    pub priority: Option<TriggerPriorityField>,
323    #[serde(default)]
324    pub budget: TriggerBudgetSpec,
325    #[serde(default)]
326    pub concurrency: Option<TriggerConcurrencyManifestSpec>,
327    #[serde(default)]
328    pub throttle: Option<TriggerThrottleManifestSpec>,
329    #[serde(default)]
330    pub rate_limit: Option<TriggerRateLimitManifestSpec>,
331    #[serde(default)]
332    pub debounce: Option<TriggerDebounceManifestSpec>,
333    #[serde(default)]
334    pub singleton: Option<TriggerSingletonManifestSpec>,
335    #[serde(default)]
336    pub batch: Option<TriggerBatchManifestSpec>,
337    #[serde(default)]
338    pub window: Option<TriggerStreamWindowManifestSpec>,
339    #[serde(default, alias = "dlq-alerts")]
340    pub dlq_alerts: Vec<TriggerDlqAlertManifestSpec>,
341    #[serde(default)]
342    pub secrets: BTreeMap<String, String>,
343    #[serde(default)]
344    pub filter: Option<String>,
345    #[serde(flatten, default)]
346    pub kind_specific: BTreeMap<String, toml::Value>,
347}
348
349#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
350pub struct TriggerSourceManifestEntry {
351    #[serde(default)]
352    pub id: Option<String>,
353    pub kind: TriggerKind,
354    pub provider: harn_vm::ProviderId,
355    #[serde(default, rename = "match")]
356    pub match_: Option<TriggerMatchExpr>,
357    #[serde(default)]
358    pub dedupe_key: Option<String>,
359    #[serde(default)]
360    pub retry: Option<TriggerRetrySpec>,
361    #[serde(default)]
362    pub priority: Option<TriggerPriorityField>,
363    #[serde(default)]
364    pub budget: Option<TriggerBudgetSpec>,
365    #[serde(default)]
366    pub concurrency: Option<TriggerConcurrencyManifestSpec>,
367    #[serde(default)]
368    pub throttle: Option<TriggerThrottleManifestSpec>,
369    #[serde(default)]
370    pub rate_limit: Option<TriggerRateLimitManifestSpec>,
371    #[serde(default)]
372    pub debounce: Option<TriggerDebounceManifestSpec>,
373    #[serde(default)]
374    pub singleton: Option<TriggerSingletonManifestSpec>,
375    #[serde(default)]
376    pub batch: Option<TriggerBatchManifestSpec>,
377    #[serde(default)]
378    pub window: Option<TriggerStreamWindowManifestSpec>,
379    #[serde(default)]
380    pub secrets: BTreeMap<String, String>,
381    #[serde(default)]
382    pub filter: Option<String>,
383    #[serde(flatten, default)]
384    pub kind_specific: BTreeMap<String, toml::Value>,
385}
386
387#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
388#[serde(rename_all = "kebab-case")]
389pub enum TriggerKind {
390    Webhook,
391    Cron,
392    Poll,
393    Stream,
394    Predicate,
395    A2aPush,
396}
397
398#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
399pub struct TriggerMatchExpr {
400    #[serde(default)]
401    pub events: Vec<String>,
402    #[serde(flatten, default)]
403    pub extra: BTreeMap<String, toml::Value>,
404}
405
406#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
407pub struct TriggerRetrySpec {
408    #[serde(default)]
409    pub max: u32,
410    #[serde(default)]
411    pub backoff: TriggerRetryBackoff,
412    #[serde(default = "default_trigger_retention_days")]
413    pub retention_days: u32,
414}
415
416#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
417#[serde(rename_all = "kebab-case")]
418pub enum TriggerRetryBackoff {
419    #[default]
420    Immediate,
421    Svix,
422}
423
424pub(crate) fn default_trigger_retention_days() -> u32 {
425    harn_vm::DEFAULT_INBOX_RETENTION_DAYS
426}
427
428impl Default for TriggerRetrySpec {
429    fn default() -> Self {
430        Self {
431            max: 0,
432            backoff: TriggerRetryBackoff::default(),
433            retention_days: default_trigger_retention_days(),
434        }
435    }
436}
437
438#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
439#[serde(rename_all = "lowercase")]
440pub enum TriggerDispatchPriority {
441    High,
442    #[default]
443    Normal,
444    Low,
445}
446
447#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
448#[serde(untagged)]
449pub enum TriggerPriorityField {
450    Dispatch(TriggerDispatchPriority),
451    Flow(TriggerPriorityManifestSpec),
452}
453
454#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
455pub struct TriggerBudgetSpec {
456    #[serde(default)]
457    pub max_cost_usd: Option<f64>,
458    #[serde(default, alias = "tokens_max")]
459    pub max_tokens: Option<u64>,
460    #[serde(default)]
461    pub daily_cost_usd: Option<f64>,
462    #[serde(default)]
463    pub hourly_cost_usd: Option<f64>,
464    #[serde(default)]
465    pub max_autonomous_decisions_per_hour: Option<u64>,
466    #[serde(default)]
467    pub max_autonomous_decisions_per_day: Option<u64>,
468    #[serde(default)]
469    pub max_concurrent: Option<u32>,
470    #[serde(default)]
471    pub on_budget_exhausted: harn_vm::TriggerBudgetExhaustionStrategy,
472}
473
474#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
475pub struct TriggerWhenBudgetSpec {
476    #[serde(default)]
477    pub max_cost_usd: Option<f64>,
478    #[serde(default)]
479    pub tokens_max: Option<u64>,
480    #[serde(default)]
481    pub timeout: Option<String>,
482}
483
484#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
485pub struct TriggerConcurrencyManifestSpec {
486    #[serde(default)]
487    pub key: Option<String>,
488    pub max: u32,
489}
490
491#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
492pub struct TriggerThrottleManifestSpec {
493    #[serde(default)]
494    pub key: Option<String>,
495    pub period: String,
496    pub max: u32,
497}
498
499#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
500pub struct TriggerRateLimitManifestSpec {
501    #[serde(default)]
502    pub key: Option<String>,
503    pub period: String,
504    pub max: u32,
505}
506
507#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
508pub struct TriggerDebounceManifestSpec {
509    pub key: String,
510    pub period: String,
511}
512
513#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
514pub struct TriggerSingletonManifestSpec {
515    #[serde(default)]
516    pub key: Option<String>,
517}
518
519#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
520pub struct TriggerBatchManifestSpec {
521    #[serde(default)]
522    pub key: Option<String>,
523    pub size: u32,
524    pub timeout: String,
525}
526
527#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
528pub struct TriggerPriorityManifestSpec {
529    pub key: String,
530    #[serde(default)]
531    pub order: Vec<String>,
532}
533
534#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
535#[serde(rename_all = "kebab-case")]
536pub enum TriggerStreamWindowMode {
537    Tumbling,
538    Sliding,
539    Session,
540}
541
542#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
543pub struct TriggerStreamWindowManifestSpec {
544    pub mode: TriggerStreamWindowMode,
545    #[serde(default)]
546    pub key: Option<String>,
547    #[serde(default)]
548    pub size: Option<String>,
549    #[serde(default)]
550    pub every: Option<String>,
551    #[serde(default)]
552    pub gap: Option<String>,
553    #[serde(default)]
554    pub max_items: Option<u32>,
555}
556
557#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
558pub struct TriggerDlqAlertManifestSpec {
559    #[serde(default)]
560    pub destinations: Vec<TriggerDlqAlertDestination>,
561    #[serde(default)]
562    pub threshold: TriggerDlqAlertThreshold,
563}
564
565#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
566pub struct TriggerDlqAlertThreshold {
567    #[serde(default, alias = "entries-in-1h")]
568    pub entries_in_1h: Option<u32>,
569    #[serde(default, alias = "percent-of-dispatches")]
570    pub percent_of_dispatches: Option<f64>,
571}
572
573#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
574#[serde(tag = "kind", rename_all = "snake_case")]
575pub enum TriggerDlqAlertDestination {
576    Slack {
577        channel: String,
578        #[serde(default)]
579        webhook_url_env: Option<String>,
580    },
581    Email {
582        address: String,
583    },
584    Webhook {
585        url: String,
586        #[serde(default)]
587        headers: BTreeMap<String, String>,
588    },
589}
590
591impl TriggerDlqAlertDestination {
592    pub fn label(&self) -> String {
593        match self {
594            Self::Slack { channel, .. } => format!("slack:{channel}"),
595            Self::Email { address } => format!("email:{address}"),
596            Self::Webhook { url, .. } => format!("webhook:{url}"),
597        }
598    }
599}
600
601#[derive(Debug, Clone, PartialEq, Eq)]
602pub enum TriggerHandlerUri {
603    Local(TriggerFunctionRef),
604    A2a {
605        target: String,
606        allow_cleartext: bool,
607    },
608    Worker {
609        queue: String,
610    },
611    Persona {
612        name: String,
613    },
614    EvalPack {
615        target: String,
616    },
617}
618
619#[derive(Debug, Clone, PartialEq, Eq)]
620pub struct TriggerFunctionRef {
621    pub raw: String,
622    pub module_name: Option<String>,
623    pub function_name: String,
624}
625
626/// `[skills]` table body.
627#[derive(Debug, Default, Clone, Deserialize)]
628#[allow(dead_code)] // `defaults` is parsed per harn#73; default application remains staged.
629pub struct SkillsConfig {
630    /// Additional filesystem roots to scan. Each entry may be a
631    /// literal directory or a glob (`packages/*/skills`). Resolved
632    /// relative to the directory holding harn.toml.
633    #[serde(default)]
634    pub paths: Vec<String>,
635    /// Override priority order. Values are layer labels —
636    /// `cli`, `env`, `project`, `manifest`, `user`, `package`,
637    /// `system`, `host`. Unlisted layers fall through to default
638    /// priority after listed ones.
639    #[serde(default)]
640    pub lookup_order: Vec<String>,
641    /// Disable entire layers. Same label set as `lookup_order`.
642    #[serde(default)]
643    pub disable: Vec<String>,
644    /// Optional remote registry base URL used to resolve
645    /// `<fingerprint>.pub` when a signer is not installed locally.
646    #[serde(default)]
647    pub signer_registry_url: Option<String>,
648    /// `[skills.defaults]` inline sub-table — applied to every
649    /// discovered skill when the field is unset in its SKILL.md
650    /// frontmatter.
651    #[serde(default)]
652    pub defaults: SkillDefaults,
653}
654
655#[derive(Debug, Default, Clone, Deserialize)]
656#[allow(dead_code)] // Parsed per harn#73; loader default application is still staged.
657pub struct SkillDefaults {
658    #[serde(default)]
659    pub tool_search: Option<String>,
660    #[serde(default)]
661    pub always_loaded: Vec<String>,
662}
663
664/// Container for `[[skill.source]]` array-of-tables.
665#[derive(Debug, Default, Clone, Deserialize)]
666pub struct SkillTables {
667    #[serde(default, rename = "source")]
668    pub sources: Vec<SkillSourceEntry>,
669}
670
671/// One `[[skill.source]]` entry. The `registry` variant is accepted
672/// for forward-compat but inert — see issue #73 and `docs/src/skills.md`
673/// for the marketplace timeline.
674#[derive(Debug, Clone, Deserialize)]
675#[serde(tag = "type", rename_all = "lowercase")]
676#[allow(dead_code)] // Git/registry skill sources are manifest-reserved by harn#73.
677pub enum SkillSourceEntry {
678    Fs {
679        path: String,
680        #[serde(default)]
681        namespace: Option<String>,
682    },
683    Git {
684        url: String,
685        #[serde(default)]
686        tag: Option<String>,
687        #[serde(default)]
688        namespace: Option<String>,
689    },
690    Registry {
691        #[serde(default)]
692        url: Option<String>,
693        #[serde(default)]
694        name: Option<String>,
695    },
696}
697
698#[derive(Debug, Default, Clone, Deserialize)]
699pub struct WorkspaceConfig {
700    /// Directory or file globs (repo-relative) that `harn check --workspace`
701    /// walks to collect the full pipeline tree in one invocation.
702    #[serde(default)]
703    pub pipelines: Vec<String>,
704}
705
706#[derive(Debug, Default, Clone, Deserialize)]
707pub struct PackageRegistryConfig {
708    /// URL or filesystem path to a TOML package index.
709    #[serde(default)]
710    pub url: Option<String>,
711}
712
713#[derive(Debug, Clone, Deserialize)]
714pub struct McpServerConfig {
715    pub name: String,
716    #[serde(default)]
717    pub transport: Option<String>,
718    #[serde(default)]
719    pub command: String,
720    #[serde(default)]
721    pub args: Vec<String>,
722    #[serde(default)]
723    pub env: HashMap<String, String>,
724    #[serde(default)]
725    pub url: String,
726    #[serde(default)]
727    pub auth_token: Option<String>,
728    #[serde(default)]
729    pub token_exchange: Option<harn_vm::mcp_oauth::McpTokenExchangeConfig>,
730    #[serde(default)]
731    pub auth: Option<McpAuthConfig>,
732    #[serde(default)]
733    pub client_id: Option<String>,
734    #[serde(default)]
735    pub client_secret: Option<String>,
736    #[serde(default)]
737    pub scopes: Option<String>,
738    #[serde(default)]
739    pub protocol_version: Option<String>,
740    #[serde(default)]
741    pub proxy_server_name: Option<String>,
742    /// When `true`, the server is NOT booted up-front. It boots on the
743    /// first `mcp_call` or on skill activation that declares it in
744    /// `requires_mcp`. See harn#75.
745    #[serde(default)]
746    pub lazy: bool,
747    /// Optional pointer to a Server Card — either an HTTP(S) URL or a
748    /// local filesystem path. When set, `mcp_server_card("name")` reads
749    /// the card from this source (cached per-process with a TTL).
750    #[serde(default)]
751    pub card: Option<String>,
752    /// How long (milliseconds) to keep a lazy server's process alive
753    /// after its last binder releases. 0 / unset → disconnect
754    /// immediately. Ignored for non-lazy servers.
755    #[serde(default, alias = "keep-alive-ms", alias = "keep_alive")]
756    pub keep_alive_ms: Option<u64>,
757}
758
759#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
760pub struct McpAuthConfig {
761    #[serde(default)]
762    pub mode: Option<harn_vm::mcp_auth::OAuthClientAuthMode>,
763    #[serde(default, alias = "client-id")]
764    pub client_id: Option<String>,
765    #[serde(
766        default,
767        alias = "client_secret_id",
768        alias = "client-secret-id",
769        alias = "client_secret_ref",
770        alias = "client-secret-ref"
771    )]
772    pub client_secret_id: Option<String>,
773    #[serde(
774        default,
775        alias = "secret_id",
776        alias = "secret-id",
777        alias = "token-secret-id"
778    )]
779    pub secret_id: Option<String>,
780    #[serde(default, alias = "scope")]
781    pub scopes: Option<String>,
782    #[serde(default, alias = "token_auth_method", alias = "token-auth-method")]
783    pub token_endpoint_auth_method: Option<String>,
784}
785
786#[derive(Debug, Clone, Deserialize)]
787#[allow(dead_code)] // Package metadata feeds authoring/publish validation tracked in harn#471.
788pub struct PackageInfo {
789    pub name: Option<String>,
790    pub version: Option<String>,
791    #[serde(default)]
792    pub evals: Vec<String>,
793    #[serde(default)]
794    pub description: Option<String>,
795    #[serde(default)]
796    pub license: Option<String>,
797    #[serde(default)]
798    pub repository: Option<String>,
799    #[serde(default, alias = "harn_version", alias = "harn_version_range")]
800    pub harn: Option<String>,
801    #[serde(default)]
802    pub docs_url: Option<String>,
803    #[serde(default)]
804    pub provenance: Option<String>,
805    /// Human-facing publisher / developer name shown in marketplace surfaces.
806    #[serde(default)]
807    pub publisher: Option<String>,
808    /// Publisher contact (email or URL) shown alongside the publisher name.
809    #[serde(default)]
810    pub contact: Option<String>,
811    /// Optional ISO-8601 authoring date. Mutation dates are better derived
812    /// from the registry/VCS, but a declared creation date is allowed.
813    #[serde(default)]
814    pub created: Option<String>,
815    #[serde(default)]
816    pub permissions: Vec<String>,
817    #[serde(default, alias = "host-requirements")]
818    pub host_requirements: Vec<String>,
819    #[serde(default)]
820    pub tools: Vec<PackageToolExport>,
821    #[serde(default)]
822    pub skills: Vec<PackageSkillExport>,
823}
824
825#[derive(Debug, Clone, Deserialize, PartialEq)]
826pub struct PackageToolExport {
827    pub name: String,
828    pub module: String,
829    #[serde(default = "default_package_tool_symbol")]
830    pub symbol: String,
831    #[serde(default)]
832    pub description: Option<String>,
833    #[serde(default)]
834    pub permissions: Vec<String>,
835    #[serde(default, alias = "host-requirements")]
836    pub host_requirements: Vec<String>,
837    #[serde(default, alias = "input-schema")]
838    pub input_schema: Option<toml::Value>,
839    #[serde(default, alias = "output-schema")]
840    pub output_schema: Option<toml::Value>,
841    #[serde(default)]
842    pub annotations: BTreeMap<String, toml::Value>,
843}
844
845pub(crate) fn default_package_tool_symbol() -> String {
846    "tools".to_string()
847}
848
849#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
850pub struct PackageSkillExport {
851    pub name: String,
852    pub path: String,
853    #[serde(default)]
854    pub description: Option<String>,
855    #[serde(default)]
856    pub permissions: Vec<String>,
857    #[serde(default, alias = "host-requirements")]
858    pub host_requirements: Vec<String>,
859}
860
861#[derive(Debug, Clone, Deserialize)]
862#[serde(untagged)]
863pub enum Dependency {
864    Table(Box<DepTable>),
865    Path(String),
866}
867
868#[derive(Debug, Clone, Default, Deserialize)]
869pub struct DepTable {
870    pub git: Option<String>,
871    #[serde(default, alias = "archive-url", alias = "archive_url")]
872    pub archive: Option<String>,
873    pub tag: Option<String>,
874    pub rev: Option<String>,
875    pub branch: Option<String>,
876    pub version: Option<String>,
877    pub path: Option<String>,
878    pub package: Option<String>,
879    #[serde(default)]
880    pub checksum: Option<String>,
881    /// Registry index URL/path the dependency was originally added from.
882    /// Persisted in the manifest so registry provenance survives
883    /// round-trips and the lockfile can compare against the registry's
884    /// latest version.
885    #[serde(default)]
886    pub registry: Option<String>,
887    /// Registry-side package name (e.g. `@burin/notion-sdk`). May differ
888    /// from the alias and from the git URL's repo name.
889    #[serde(default, alias = "registry-name")]
890    pub registry_name: Option<String>,
891    /// Registry version specifier the dependency was added against.
892    #[serde(default, alias = "registry-version")]
893    pub registry_version: Option<String>,
894    /// Immutable commit recorded by registry v2 for a Git-backed version.
895    #[serde(default, alias = "registry-commit")]
896    pub registry_commit: Option<String>,
897    /// Registry-v2 evidence URL for the selected published version.
898    #[serde(default, alias = "registry-provenance")]
899    pub registry_provenance: Option<String>,
900}
901
902impl Dependency {
903    pub(crate) fn git_url(&self) -> Option<&str> {
904        match self {
905            Dependency::Table(t) => t.git.as_deref(),
906            Dependency::Path(_) => None,
907        }
908    }
909
910    pub(crate) fn archive_url(&self) -> Option<&str> {
911        match self {
912            Dependency::Table(t) => t.archive.as_deref(),
913            Dependency::Path(_) => None,
914        }
915    }
916
917    pub(crate) fn rev(&self) -> Option<&str> {
918        match self {
919            Dependency::Table(t) => t.rev.as_deref(),
920            Dependency::Path(_) => None,
921        }
922    }
923
924    pub(crate) fn tag(&self) -> Option<&str> {
925        match self {
926            Dependency::Table(t) => t.tag.as_deref(),
927            Dependency::Path(_) => None,
928        }
929    }
930
931    pub(crate) fn branch(&self) -> Option<&str> {
932        match self {
933            Dependency::Table(t) => t.branch.as_deref(),
934            Dependency::Path(_) => None,
935        }
936    }
937
938    pub(crate) fn version(&self) -> Option<&str> {
939        match self {
940            Dependency::Table(t) => t.version.as_deref(),
941            Dependency::Path(_) => None,
942        }
943    }
944
945    pub(crate) fn requires_git(&self) -> bool {
946        self.git_url().is_some()
947    }
948
949    pub(crate) fn local_path(&self) -> Option<&str> {
950        match self {
951            Dependency::Table(t) => t.path.as_deref(),
952            Dependency::Path(p) => Some(p.as_str()),
953        }
954    }
955}
956
957pub(crate) fn validate_package_alias(alias: &str) -> Result<(), PackageError> {
958    if harn_modules::package_snapshot::is_valid_package_name(alias) {
959        Ok(())
960    } else {
961        Err(PackageError::Validation(format!(
962            "invalid dependency alias {alias:?}; use ASCII letters, numbers, '.', '_' or '-'"
963        )))
964    }
965}
966
967pub(crate) fn toml_string_literal(value: &str) -> Result<String, PackageError> {
968    use std::fmt::Write as _;
969
970    let mut encoded = String::with_capacity(value.len() + 2);
971    encoded.push('"');
972    for ch in value.chars() {
973        match ch {
974            '\u{08}' => encoded.push_str("\\b"),
975            '\t' => encoded.push_str("\\t"),
976            '\n' => encoded.push_str("\\n"),
977            '\u{0C}' => encoded.push_str("\\f"),
978            '\r' => encoded.push_str("\\r"),
979            '"' => encoded.push_str("\\\""),
980            '\\' => encoded.push_str("\\\\"),
981            ch if ch <= '\u{1F}' || ch == '\u{7F}' => {
982                write!(&mut encoded, "\\u{:04X}", ch as u32).map_err(|error| {
983                    PackageError::Manifest(format!("failed to encode TOML string: {error}"))
984                })?;
985            }
986            ch => encoded.push(ch),
987        }
988    }
989    encoded.push('"');
990    Ok(encoded)
991}
992#[derive(Debug, Default, Clone)]
993pub struct RuntimeExtensions {
994    pub root_manifest: Option<Manifest>,
995    pub root_manifest_path: Option<PathBuf>,
996    pub root_manifest_dir: Option<PathBuf>,
997    pub(crate) runtime_personas: Vec<ResolvedRuntimePersona>,
998    pub llm: Option<harn_vm::llm_config::ProvidersConfig>,
999    pub capabilities: Option<harn_vm::llm::capabilities::CapabilitiesFile>,
1000    pub hooks: Vec<ResolvedHookConfig>,
1001    pub triggers: Vec<ResolvedTriggerConfig>,
1002    pub handoff_routes: Vec<harn_vm::HandoffRouteConfig>,
1003    pub provider_connectors: Vec<ResolvedProviderConnectorConfig>,
1004}
1005
1006#[derive(Debug, Clone, Deserialize)]
1007pub struct ProviderConnectorManifest {
1008    #[serde(default)]
1009    pub harn: Option<String>,
1010    #[serde(default)]
1011    pub rust: Option<String>,
1012}
1013
1014#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
1015pub struct ProviderOAuthManifest {
1016    #[serde(default, alias = "auth_url", alias = "authorization-endpoint")]
1017    pub authorization_endpoint: Option<String>,
1018    #[serde(default, alias = "token_url", alias = "token-endpoint")]
1019    pub token_endpoint: Option<String>,
1020    #[serde(default, alias = "registration_url", alias = "registration-endpoint")]
1021    pub registration_endpoint: Option<String>,
1022    #[serde(default)]
1023    pub resource: Option<String>,
1024    #[serde(default, alias = "scope")]
1025    pub scopes: Option<String>,
1026    #[serde(default, alias = "client-id")]
1027    pub client_id: Option<String>,
1028    #[serde(default, alias = "client-secret")]
1029    pub client_secret: Option<String>,
1030    #[serde(default, alias = "token_auth_method", alias = "token-auth-method")]
1031    pub token_endpoint_auth_method: Option<String>,
1032}
1033
1034#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
1035pub struct ConnectorCapabilities {
1036    pub webhook: bool,
1037    pub oauth: bool,
1038    pub rate_limit: bool,
1039    pub pagination: bool,
1040    pub graphql: bool,
1041    pub streaming: bool,
1042}
1043
1044impl ConnectorCapabilities {
1045    pub const FEATURES: [&'static str; 6] = [
1046        "webhook",
1047        "oauth",
1048        "rate_limit",
1049        "pagination",
1050        "graphql",
1051        "streaming",
1052    ];
1053
1054    fn enable(&mut self, feature: &str) -> Result<(), String> {
1055        match normalize_connector_capability(feature).as_str() {
1056            "webhook" => self.webhook = true,
1057            "oauth" => self.oauth = true,
1058            "rate_limit" => self.rate_limit = true,
1059            "pagination" => self.pagination = true,
1060            "graphql" => self.graphql = true,
1061            "streaming" => self.streaming = true,
1062            other => {
1063                return Err(format!(
1064                    "unknown connector capability '{feature}' (normalized as '{other}')"
1065                ));
1066            }
1067        }
1068        Ok(())
1069    }
1070}
1071
1072#[derive(Debug, Default, Deserialize)]
1073struct ConnectorCapabilitiesTable {
1074    #[serde(default)]
1075    webhook: bool,
1076    #[serde(default)]
1077    oauth: bool,
1078    #[serde(default, alias = "rate-limit")]
1079    rate_limit: bool,
1080    #[serde(default)]
1081    pagination: bool,
1082    #[serde(default)]
1083    graphql: bool,
1084    #[serde(default)]
1085    streaming: bool,
1086}
1087
1088impl From<ConnectorCapabilitiesTable> for ConnectorCapabilities {
1089    fn from(value: ConnectorCapabilitiesTable) -> Self {
1090        Self {
1091            webhook: value.webhook,
1092            oauth: value.oauth,
1093            rate_limit: value.rate_limit,
1094            pagination: value.pagination,
1095            graphql: value.graphql,
1096            streaming: value.streaming,
1097        }
1098    }
1099}
1100
1101impl<'de> Deserialize<'de> for ConnectorCapabilities {
1102    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1103    where
1104        D: serde::Deserializer<'de>,
1105    {
1106        #[derive(Deserialize)]
1107        #[serde(untagged)]
1108        enum RawConnectorCapabilities {
1109            List(Vec<String>),
1110            Table(ConnectorCapabilitiesTable),
1111        }
1112
1113        match RawConnectorCapabilities::deserialize(deserializer)? {
1114            RawConnectorCapabilities::List(features) => {
1115                let mut capabilities = ConnectorCapabilities::default();
1116                for feature in features {
1117                    capabilities
1118                        .enable(&feature)
1119                        .map_err(serde::de::Error::custom)?;
1120                }
1121                Ok(capabilities)
1122            }
1123            RawConnectorCapabilities::Table(table) => Ok(table.into()),
1124        }
1125    }
1126}
1127
1128pub fn normalize_connector_capability(feature: &str) -> String {
1129    feature.trim().to_lowercase().replace('-', "_")
1130}
1131
1132#[derive(Debug, Clone, Default, Deserialize)]
1133pub struct ConnectorContractConfig {
1134    #[serde(default)]
1135    pub version: Option<u32>,
1136    #[serde(default)]
1137    pub fixtures: Vec<ConnectorContractFixture>,
1138}
1139
1140#[derive(Debug, Clone, Deserialize)]
1141pub struct ConnectorContractFixture {
1142    pub provider: harn_vm::ProviderId,
1143    #[serde(default)]
1144    pub name: Option<String>,
1145    #[serde(default)]
1146    pub kind: Option<String>,
1147    #[serde(default)]
1148    pub headers: BTreeMap<String, String>,
1149    #[serde(default)]
1150    pub query: BTreeMap<String, String>,
1151    #[serde(default)]
1152    pub metadata: Option<toml::Value>,
1153    #[serde(default)]
1154    pub body: Option<String>,
1155    #[serde(default)]
1156    pub body_json: Option<toml::Value>,
1157    #[serde(default)]
1158    pub expect_type: Option<String>,
1159    #[serde(default)]
1160    pub expect_kind: Option<String>,
1161    #[serde(default)]
1162    pub expect_dedupe_key: Option<String>,
1163    #[serde(default)]
1164    pub expect_signature_state: Option<String>,
1165    #[serde(default)]
1166    pub expect_payload_contains: Option<toml::Value>,
1167    #[serde(default)]
1168    pub expect_response_status: Option<u16>,
1169    #[serde(default)]
1170    pub expect_response_body: Option<toml::Value>,
1171    #[serde(default)]
1172    pub expect_event_count: Option<usize>,
1173    #[serde(default)]
1174    pub expect_error_contains: Option<String>,
1175}
1176
1177#[derive(Debug, Clone, PartialEq, Eq)]
1178pub enum ResolvedProviderConnectorKind {
1179    Harn { module: String },
1180    RustBuiltin,
1181    Invalid(String),
1182}
1183
1184#[derive(Debug, Clone)]
1185pub struct ResolvedHookConfig {
1186    pub event: harn_vm::orchestration::HookEvent,
1187    pub pattern: String,
1188    pub handler: String,
1189    pub manifest_dir: PathBuf,
1190    pub package_name: Option<String>,
1191    pub exports: HashMap<String, String>,
1192}
1193
1194#[derive(Debug, Clone)]
1195pub struct ResolvedTriggerConfig {
1196    pub id: String,
1197    pub kind: TriggerKind,
1198    pub provider: harn_vm::ProviderId,
1199    pub autonomy_tier: harn_vm::AutonomyTier,
1200    pub match_: TriggerMatchExpr,
1201    pub when: Option<String>,
1202    pub when_budget: Option<TriggerWhenBudgetSpec>,
1203    pub handler: String,
1204    pub dedupe_key: Option<String>,
1205    pub retry: TriggerRetrySpec,
1206    pub dispatch_priority: TriggerDispatchPriority,
1207    pub budget: TriggerBudgetSpec,
1208    pub concurrency: Option<TriggerConcurrencyManifestSpec>,
1209    pub throttle: Option<TriggerThrottleManifestSpec>,
1210    pub rate_limit: Option<TriggerRateLimitManifestSpec>,
1211    pub debounce: Option<TriggerDebounceManifestSpec>,
1212    pub singleton: Option<TriggerSingletonManifestSpec>,
1213    pub batch: Option<TriggerBatchManifestSpec>,
1214    pub window: Option<TriggerStreamWindowManifestSpec>,
1215    pub priority_flow: Option<TriggerPriorityManifestSpec>,
1216    pub secrets: BTreeMap<String, String>,
1217    pub filter: Option<String>,
1218    pub kind_specific: BTreeMap<String, toml::Value>,
1219    pub manifest_dir: PathBuf,
1220    pub manifest_path: PathBuf,
1221    pub package_name: Option<String>,
1222    pub exports: HashMap<String, String>,
1223    pub execution_guard: Option<Arc<harn_modules::package_execution::PackageExecutionGuard>>,
1224    pub table_index: usize,
1225    pub shape_error: Option<String>,
1226}
1227
1228#[derive(Debug, Clone)]
1229#[allow(dead_code)] // Collected bindings are validated now and consumed by harn#159 dispatcher work.
1230pub struct CollectedManifestTrigger {
1231    pub config: ResolvedTriggerConfig,
1232    pub handler: CollectedTriggerHandler,
1233    pub when: Option<CollectedTriggerPredicate>,
1234    pub flow_control: harn_vm::TriggerFlowControlConfig,
1235}
1236
1237#[derive(Debug, Clone)]
1238#[allow(dead_code)] // Remote targets and closures are retained for harn#159 trigger execution.
1239pub enum CollectedTriggerHandler {
1240    Local {
1241        reference: TriggerFunctionRef,
1242        callable: harn_vm::VmCallable,
1243    },
1244    A2a {
1245        target: String,
1246        allow_cleartext: bool,
1247    },
1248    Worker {
1249        queue: String,
1250    },
1251    Persona {
1252        binding: harn_vm::PersonaRuntimeBinding,
1253        callable: harn_vm::VmCallable,
1254    },
1255    EvalPack {
1256        target: String,
1257        manifest: Box<harn_vm::orchestration::EvalPackManifest>,
1258        ledger_options: Option<serde_json::Value>,
1259    },
1260}
1261#[derive(Debug, Clone)]
1262#[allow(dead_code)] // Predicate callables are validated now and reused by harn#161 dispatch gating.
1263pub struct CollectedTriggerPredicate {
1264    pub reference: TriggerFunctionRef,
1265    pub callable: harn_vm::VmCallable,
1266}
1267
1268pub(crate) type ManifestModuleCacheKey = (PathBuf, Option<String>, Option<String>);
1269pub(crate) type ManifestModuleExports = BTreeMap<String, Arc<harn_vm::VmClosure>>;
1270
1271static MANIFEST_PROVIDER_SCHEMA_LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
1272
1273pub(crate) async fn lock_manifest_provider_schemas() -> tokio::sync::MutexGuard<'static, ()> {
1274    MANIFEST_PROVIDER_SCHEMA_LOCK
1275        .get_or_init(|| tokio::sync::Mutex::new(()))
1276        .lock()
1277        .await
1278}
1279
1280fn llm_manifest_diagnostics(content: &str) -> Vec<harn_vm::llm_config::ProviderConfigDiagnostic> {
1281    let Ok(value) = toml::from_str::<toml::Value>(content) else {
1282        return Vec::new();
1283    };
1284    let Some(llm) = value.get("llm") else {
1285        return Vec::new();
1286    };
1287    let Ok(llm_src) = toml::to_string(llm) else {
1288        return Vec::new();
1289    };
1290    let Ok(parsed) = harn_vm::llm_config::parse_config_toml_with_diagnostics(&llm_src) else {
1291        return Vec::new();
1292    };
1293    parsed
1294        .diagnostics
1295        .into_iter()
1296        .map(|mut diagnostic| {
1297            if !diagnostic.path.is_empty() {
1298                diagnostic.path = format!("llm.{}", diagnostic.path);
1299            }
1300            diagnostic
1301        })
1302        .collect()
1303}
1304
1305pub(crate) fn read_manifest_from_path(path: &Path) -> Result<Manifest, PackageError> {
1306    let content = fs::read_to_string(path).map_err(|error| {
1307        if error.kind() == std::io::ErrorKind::NotFound {
1308            PackageError::Manifest(format!(
1309                "No {} found in {}.",
1310                MANIFEST,
1311                path.parent().unwrap_or_else(|| Path::new(".")).display()
1312            ))
1313        } else {
1314            PackageError::Manifest(format!("failed to read {}: {error}", path.display()))
1315        }
1316    })?;
1317    let manifest = toml::from_str::<Manifest>(&content).map_err(|error| {
1318        PackageError::Manifest(format!("failed to parse {}: {error}", path.display()))
1319    })?;
1320    for diagnostic in llm_manifest_diagnostics(&content) {
1321        eprintln!("[llm_config] warning in {}: {diagnostic}", path.display());
1322    }
1323    Ok(manifest)
1324}
1325
1326/// Load the `[workspace]` config and the directory of the `harn.toml`
1327/// it came from. Paths in the returned config are left as-is (callers
1328/// resolve them against the returned `manifest_dir`).
1329pub fn load_workspace_config(anchor: Option<&Path>) -> Option<(WorkspaceConfig, PathBuf)> {
1330    let anchor = anchor
1331        .map(Path::to_path_buf)
1332        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
1333    let (manifest, dir) = nearest_manifest_or_warn(&anchor)?;
1334    Some((manifest.workspace, dir))
1335}
1336
1337pub fn load_package_eval_pack_paths(anchor: Option<&Path>) -> Result<Vec<PathBuf>, PackageError> {
1338    let anchor = anchor
1339        .map(Path::to_path_buf)
1340        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
1341    let Some((manifest, dir)) = load_nearest_manifest(&anchor).into_result()? else {
1342        return Err(PackageError::Manifest(
1343            "no harn.toml found for package eval discovery".to_string(),
1344        ));
1345    };
1346
1347    let ctx = ManifestContext { manifest, dir };
1348    let mut paths = eval_pack_paths_from_manifest(&ctx.manifest, &ctx.dir)?;
1349    paths.extend(installed_package_eval_pack_paths(&ctx)?);
1350    paths.sort();
1351    paths.dedup();
1352    if paths.is_empty() {
1353        return Err(PackageError::Manifest(
1354            "package declares no eval packs; add [package].evals, harn.eval.toml, or install a dependency that ships eval packs".to_string(),
1355        ));
1356    }
1357    for path in &paths {
1358        if !path.is_file() {
1359            return Err(PackageError::Manifest(format!(
1360                "eval pack does not exist: {}",
1361                path.display()
1362            )));
1363        }
1364    }
1365    Ok(paths)
1366}
1367
1368fn eval_pack_paths_from_manifest(
1369    manifest: &Manifest,
1370    manifest_dir: &Path,
1371) -> Result<Vec<PathBuf>, PackageError> {
1372    let declared = manifest
1373        .package
1374        .as_ref()
1375        .map(|package| package.evals.clone())
1376        .unwrap_or_default();
1377    let paths = if declared.is_empty() {
1378        let default_pack = manifest_dir.join("harn.eval.toml");
1379        if default_pack.is_file() {
1380            vec![default_pack]
1381        } else {
1382            Vec::new()
1383        }
1384    } else {
1385        declared
1386            .iter()
1387            .map(|entry| {
1388                let path = PathBuf::from(entry);
1389                if path.is_absolute() {
1390                    path
1391                } else {
1392                    manifest_dir.join(path)
1393                }
1394            })
1395            .collect()
1396    };
1397    for path in &paths {
1398        if !path.is_file() {
1399            return Err(PackageError::Manifest(format!(
1400                "eval pack does not exist: {}",
1401                path.display()
1402            )));
1403        }
1404    }
1405    Ok(paths)
1406}
1407
1408fn installed_package_eval_pack_paths(ctx: &ManifestContext) -> Result<Vec<PathBuf>, PackageError> {
1409    let Some(snapshot) = dependency_package_snapshot(&ctx.manifest, &ctx.dir)? else {
1410        return Ok(Vec::new());
1411    };
1412    let lock = LockFile::load(snapshot.lock_path())?.ok_or_else(|| {
1413        PackageError::Lockfile(format!(
1414            "published package generation is missing {}",
1415            snapshot.lock_path().display()
1416        ))
1417    })?;
1418    let mut paths = Vec::new();
1419    let packages_dir = snapshot.packages_root();
1420    for entry in &lock.packages {
1421        validate_package_alias(&entry.name)?;
1422        let package_dir = packages_dir.join(&entry.name);
1423        if package_dir.is_dir() {
1424            if let Some(manifest) = read_package_manifest_from_dir(&package_dir)? {
1425                paths.extend(eval_pack_paths_from_manifest(&manifest, &package_dir)?);
1426            }
1427            continue;
1428        }
1429
1430        let package_file = packages_dir.join(format!("{}.harn", entry.name));
1431        if package_file.is_file() {
1432            continue;
1433        }
1434
1435        return Err(PackageError::Manifest(format!(
1436            "installed package {} is missing under {}; run `harn install`",
1437            entry.name,
1438            packages_dir.display()
1439        )));
1440    }
1441    Ok(paths)
1442}
1443
1444#[derive(Debug, Clone)]
1445pub(crate) struct ManifestContext {
1446    pub(crate) manifest: Manifest,
1447    pub(crate) dir: PathBuf,
1448}
1449
1450impl ManifestContext {
1451    pub(crate) fn manifest_path(&self) -> PathBuf {
1452        self.dir.join(MANIFEST)
1453    }
1454
1455    pub(crate) fn lock_path(&self) -> PathBuf {
1456        self.dir.join(LOCK_FILE)
1457    }
1458}
1459
1460#[cfg(test)]
1461mod tests {
1462    use super::*;
1463    use crate::package::test_support::{current_packages_dir, TestWorkspace};
1464
1465    #[test]
1466    fn rules_table_parses_camel_and_kebab_dir_keys() {
1467        // The documented `ruleDirs` camelCase form and the kebab alias both map
1468        // to `rule_dirs` (#2843).
1469        let camel: Manifest =
1470            toml::from_str("[rules]\nruleDirs = [\"rules\", \"vendor/rules\"]\n").unwrap();
1471        assert_eq!(camel.rules.rule_dirs, vec!["rules", "vendor/rules"]);
1472
1473        let kebab: Manifest = toml::from_str("[rules]\nrule-dirs = [\"r\"]\n").unwrap();
1474        assert_eq!(kebab.rules.rule_dirs, vec!["r"]);
1475
1476        let native: Manifest =
1477            toml::from_str("[rules]\nnativeRuleDirs = [\"native-rules\"]\n").unwrap();
1478        assert_eq!(native.rules.native_rule_dirs, vec!["native-rules"]);
1479
1480        let native_kebab: Manifest =
1481            toml::from_str("[rules]\nnative-rule-dirs = [\"nr\"]\n").unwrap();
1482        assert_eq!(native_kebab.rules.native_rule_dirs, vec!["nr"]);
1483
1484        // No `[rules]` table → empty discovery, never an error.
1485        let none: Manifest = toml::from_str("[package]\nname = \"x\"\n").unwrap();
1486        assert!(none.rules.rule_dirs.is_empty());
1487        assert!(none.rules.native_rule_dirs.is_empty());
1488    }
1489
1490    #[test]
1491    fn llm_manifest_diagnostics_report_unknown_model_fields() {
1492        let diagnostics = llm_manifest_diagnostics(
1493            r#"
1494[llm.models."demo/model"]
1495name = "Demo"
1496provider = "demo"
1497context_window = 4096
1498fast_mode = true
1499"#,
1500        );
1501        let texts: Vec<String> = diagnostics
1502            .into_iter()
1503            .map(|diagnostic| diagnostic.to_string())
1504            .collect();
1505        assert!(
1506            texts.iter().any(
1507                |diagnostic| diagnostic.contains("llm.models.demo/model.fast_mode")
1508                    && diagnostic.contains("serving_tiers")
1509            ),
1510            "expected manifest [llm] unknown-field diagnostic, got {texts:?}"
1511        );
1512    }
1513
1514    #[test]
1515    fn package_eval_pack_paths_use_package_manifest_entries() {
1516        let tmp = tempfile::tempdir().unwrap();
1517        let root = tmp.path();
1518        fs::create_dir_all(root.join(".git")).unwrap();
1519        fs::create_dir_all(root.join("evals")).unwrap();
1520        fs::write(
1521            root.join(MANIFEST),
1522            r#"
1523    [package]
1524    name = "demo"
1525    version = "0.1.0"
1526    evals = ["evals/webhook.toml"]
1527    "#,
1528        )
1529        .unwrap();
1530        fs::write(
1531            root.join("evals/webhook.toml"),
1532            "version = 1\n[[cases]]\nrun = \"run.json\"\n",
1533        )
1534        .unwrap();
1535
1536        let paths = load_package_eval_pack_paths(Some(&root.join("src/main.harn"))).unwrap();
1537
1538        assert_eq!(paths, vec![root.join("evals/webhook.toml")]);
1539        assert!(
1540            !root.join(".harn").exists(),
1541            "loading project eval packs without dependencies must remain read-only"
1542        );
1543    }
1544
1545    #[test]
1546    fn package_eval_pack_paths_include_installed_package_evals() {
1547        let dependency_tmp = tempfile::tempdir().unwrap();
1548        let dependency = dependency_tmp.path().join("coding-pack");
1549        fs::create_dir_all(dependency.join("evals")).unwrap();
1550        fs::write(
1551            dependency.join(MANIFEST),
1552            r#"
1553[package]
1554name = "coding-pack"
1555version = "0.1.0"
1556evals = ["evals/coding.toml"]
1557"#,
1558        )
1559        .unwrap();
1560        fs::write(
1561            dependency.join("evals/run.json"),
1562            serde_json::to_string_pretty(&serde_json::json!({
1563                "_type": "workflow_run",
1564                "id": "run_1",
1565                "workflow_id": "workflow_1",
1566                "status": "completed",
1567                "usage": {
1568                    "total_duration_ms": 12,
1569                    "total_cost": 0.01,
1570                    "input_tokens": 3,
1571                    "output_tokens": 4,
1572                    "call_count": 1,
1573                    "models": ["mock"]
1574                },
1575                "replay_fixture": {
1576                    "_type": "replay_fixture",
1577                    "expected_status": "completed"
1578                }
1579            }))
1580            .unwrap(),
1581        )
1582        .unwrap();
1583        fs::write(
1584            dependency.join("evals/coding.toml"),
1585            r#"
1586version = 1
1587id = "coding-pack"
1588trials = 2
1589
1590[package]
1591name = "coding-pack"
1592version = "0.1.0"
1593source = "path:test"
1594templates = ["templates/rubric.harn.prompt"]
1595
1596[metadata]
1597model = "mock-model"
1598commit = "commit-a"
1599
1600[[cases]]
1601id = "case-a"
1602run = "run.json"
1603rubrics = ["status"]
1604
1605[[rubrics]]
1606id = "status"
1607kind = "deterministic"
1608
1609[[rubrics.assertions]]
1610kind = "run-status"
1611expected = "completed"
1612"#,
1613        )
1614        .unwrap();
1615
1616        let helper = dependency_tmp.path().join("helper-lib");
1617        fs::create_dir_all(&helper).unwrap();
1618        fs::write(
1619            helper.join(MANIFEST),
1620            r#"
1621[package]
1622name = "helper-lib"
1623version = "0.1.0"
1624"#,
1625        )
1626        .unwrap();
1627
1628        let project_tmp = tempfile::tempdir().unwrap();
1629        let root = project_tmp.path();
1630        let workspace = TestWorkspace::new(root);
1631        fs::create_dir_all(root.join(".git")).unwrap();
1632        fs::write(
1633            root.join(MANIFEST),
1634            format!(
1635                r#"
1636[package]
1637name = "workspace"
1638version = "0.1.0"
1639
1640[dependencies]
1641coding-pack = {{ path = {} }}
1642helper-lib = {{ path = {} }}
1643"#,
1644                crate::format::toml_basic_string_literal(&dependency.display().to_string()),
1645                crate::format::toml_basic_string_literal(&helper.display().to_string())
1646            ),
1647        )
1648        .unwrap();
1649
1650        install_packages_in(workspace.env(), false, None, false).unwrap();
1651
1652        let paths = load_package_eval_pack_paths(Some(&root.join("src/main.harn"))).unwrap();
1653        assert_eq!(
1654            paths,
1655            vec![current_packages_dir(root)
1656                .join("coding-pack")
1657                .join("evals/coding.toml")]
1658        );
1659
1660        harn_vm::event_log::reset_active_event_log();
1661        let manifest = harn_vm::orchestration::load_eval_pack_manifest(&paths[0]).unwrap();
1662        let package = manifest.package.as_ref().expect("package descriptor");
1663        assert_eq!(package.name.as_deref(), Some("coding-pack"));
1664        assert_eq!(package.templates, vec!["templates/rubric.harn.prompt"]);
1665
1666        let report = harn_vm::orchestration::evaluate_eval_pack_manifest_resumable(
1667            &manifest,
1668            Some(serde_json::json!({
1669                "namespace": "installed-pack-evals",
1670                "suite": "coding-pack",
1671                "model": "mock-model",
1672                "commit": "commit-a",
1673                "branch": "main"
1674            })),
1675        )
1676        .unwrap();
1677        assert!(report.pass);
1678        assert_eq!(report.trial_count, 2);
1679        assert_eq!(report.run_state.ledger_rows_inserted, 2);
1680        assert_eq!(report.stats_rows.len(), 1);
1681        assert_eq!(report.stats_rows[0].trials, 2);
1682        assert!(!report.stats_rows[0].case_fingerprint.is_empty());
1683        assert_eq!(
1684            report.harness_config_fingerprint,
1685            report.stats_rows[0].harness_config_fingerprint
1686        );
1687
1688        let ledger = harn_vm::orchestration::eval_ledger_read_report(Some(serde_json::json!({
1689            "namespace": "installed-pack-evals",
1690            "suite": "coding-pack",
1691            "model": "mock-model",
1692            "commit": "commit-a"
1693        })))
1694        .unwrap();
1695        assert_eq!(ledger.rows.len(), 2);
1696        harn_vm::event_log::reset_active_event_log();
1697    }
1698    #[test]
1699    fn preflight_severity_parsing_accepts_synonyms() {
1700        assert_eq!(
1701            PreflightSeverity::from_opt(Some("warning")),
1702            PreflightSeverity::Warning
1703        );
1704        assert_eq!(
1705            PreflightSeverity::from_opt(Some("WARN")),
1706            PreflightSeverity::Warning
1707        );
1708        assert_eq!(
1709            PreflightSeverity::from_opt(Some("off")),
1710            PreflightSeverity::Off
1711        );
1712        assert_eq!(
1713            PreflightSeverity::from_opt(Some("allow")),
1714            PreflightSeverity::Off
1715        );
1716        assert_eq!(
1717            PreflightSeverity::from_opt(Some("error")),
1718            PreflightSeverity::Error
1719        );
1720        assert_eq!(PreflightSeverity::from_opt(None), PreflightSeverity::Error);
1721        // Unknown values fall back to the safe default (error).
1722        assert_eq!(
1723            PreflightSeverity::from_opt(Some("bogus")),
1724            PreflightSeverity::Error
1725        );
1726    }
1727
1728    #[test]
1729    fn load_check_config_walks_up_from_nested_file() {
1730        let tmp = tempfile::tempdir().unwrap();
1731        let root = tmp.path();
1732        // Mark root as project boundary so walk-up terminates here.
1733        std::fs::create_dir_all(root.join(".git")).unwrap();
1734        fs::write(
1735            root.join(MANIFEST),
1736            r#"
1737    [check]
1738    preflight_severity = "warning"
1739    preflight_allow = ["custom.scan", "runtime.*"]
1740    host_capabilities_path = "./schemas/host-caps.json"
1741
1742    [workspace]
1743    pipelines = ["pipelines", "scripts"]
1744    "#,
1745        )
1746        .unwrap();
1747        let nested = root.join("src").join("deep");
1748        std::fs::create_dir_all(&nested).unwrap();
1749        let harn_file = nested.join("pipeline.harn");
1750        fs::write(&harn_file, "pipeline main() {}\n").unwrap();
1751
1752        let cfg = load_check_config(Some(&harn_file));
1753        assert_eq!(cfg.preflight_severity.as_deref(), Some("warning"));
1754        assert_eq!(cfg.preflight_allow, vec!["custom.scan", "runtime.*"]);
1755        let caps_path = cfg.host_capabilities_path.expect("host caps path");
1756        assert!(
1757            caps_path.ends_with("schemas/host-caps.json")
1758                || caps_path.ends_with("schemas\\host-caps.json"),
1759            "unexpected absolutized path: {caps_path}"
1760        );
1761
1762        let (workspace, manifest_dir) =
1763            load_workspace_config(Some(&harn_file)).expect("workspace manifest");
1764        assert_eq!(workspace.pipelines, vec!["pipelines", "scripts"]);
1765        // Walk-up lands on the directory containing the harn.toml.
1766        assert_eq!(manifest_dir, root);
1767    }
1768
1769    #[test]
1770    fn toml_string_literal_escapes_all_basic_control_characters() {
1771        let literal = toml_string_literal("a\u{08}\t\n\u{0C}\r\"\\\u{07}z").unwrap();
1772        let parsed: toml::Value = toml::from_str(&format!("value = {literal}\n")).unwrap();
1773        assert_eq!(
1774            parsed.get("value").and_then(toml::Value::as_str),
1775            Some("a\u{08}\t\n\u{0C}\r\"\\\u{07}z")
1776        );
1777    }
1778
1779    #[test]
1780    fn orchestrator_drain_config_parses_defaults_and_overrides() {
1781        let default_manifest: Manifest = toml::from_str(
1782            r#"
1783    [package]
1784    name = "fixture"
1785    "#,
1786        )
1787        .unwrap();
1788        assert_eq!(default_manifest.orchestrator.drain.max_items, 1024);
1789        assert_eq!(default_manifest.orchestrator.drain.deadline_seconds, 30);
1790        assert_eq!(default_manifest.orchestrator.pumps.max_outstanding, 64);
1791
1792        let configured: Manifest = toml::from_str(
1793            r#"
1794    [package]
1795    name = "fixture"
1796
1797    [orchestrator]
1798    drain.max_items = 77
1799    drain.deadline_seconds = 12
1800    pumps.max_outstanding = 3
1801    "#,
1802        )
1803        .unwrap();
1804        assert_eq!(configured.orchestrator.drain.max_items, 77);
1805        assert_eq!(configured.orchestrator.drain.deadline_seconds, 12);
1806        assert_eq!(configured.orchestrator.pumps.max_outstanding, 3);
1807    }
1808
1809    #[test]
1810    fn load_check_config_stops_at_git_boundary() {
1811        let tmp = tempfile::tempdir().unwrap();
1812        // An ancestor harn.toml above .git must NOT be picked up.
1813        fs::write(
1814            tmp.path().join(MANIFEST),
1815            "[check]\npreflight_severity = \"off\"\n",
1816        )
1817        .unwrap();
1818        let project = tmp.path().join("project");
1819        std::fs::create_dir_all(project.join(".git")).unwrap();
1820        let inner = project.join("src");
1821        std::fs::create_dir_all(&inner).unwrap();
1822        let harn_file = inner.join("main.harn");
1823        fs::write(&harn_file, "pipeline main() {}\n").unwrap();
1824        let cfg = load_check_config(Some(&harn_file));
1825        assert!(
1826            cfg.preflight_severity.is_none(),
1827            "must not inherit harn.toml from outside the .git boundary"
1828        );
1829    }
1830}