Skip to main content

harn_cli/package/
manifest.rs

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