mati_core/mcp/protocol/inputs.rs
1use serde::{Deserialize, Serialize};
2
3use crate::hooks::decide::Action;
4use crate::store::{
5 PolicyMode as StorePolicyMode, PolicyRecord, PolicyStage as StorePolicyStage,
6 Priority as StorePriority, ReceiptSource as StoreReceiptSource,
7};
8
9#[derive(Debug, Serialize, Deserialize)]
10#[serde(deny_unknown_fields)]
11pub struct GetInput {
12 pub key: String,
13}
14
15#[derive(Debug, Serialize, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct HookEvaluateInput {
18 pub file_key: String,
19 #[serde(default)]
20 pub include_recent: bool,
21 /// Actor scope for the consult-receipt lookup: `agent_id` for a subagent,
22 /// `None` (global) for the main thread. Drives per-actor enforcement.
23 #[serde(default)]
24 pub actor: Option<String>,
25}
26
27#[derive(Debug, Serialize, Deserialize)]
28#[serde(deny_unknown_fields)]
29pub struct PolicyEvaluateInput {
30 pub action: Action,
31 #[serde(default)]
32 pub actor: Option<String>,
33 /// Original command text used only for record-only bypass detection.
34 #[serde(default)]
35 pub raw_command: Option<String>,
36}
37
38#[derive(Debug, Serialize, Deserialize)]
39pub struct PolicyVerdict {
40 pub key: String,
41 pub stage: StorePolicyStage,
42 pub mode: StorePolicyMode,
43 pub rule: String,
44 pub reason: String,
45 pub severity: StorePriority,
46 pub requires_key: String,
47 #[serde(default)]
48 pub via: Vec<StoreReceiptSource>,
49 pub satisfied: bool,
50 /// Snapshot of the global enforcement mode used for block degradation.
51 #[serde(default)]
52 pub strict: bool,
53}
54
55#[derive(Debug, Serialize, Deserialize)]
56pub struct PolicyEvaluateResult {
57 pub verdicts: Vec<PolicyVerdict>,
58 /// Policy key whose literal was found in an otherwise unclassified command.
59 #[serde(default)]
60 pub bypass_key: Option<String>,
61}
62
63#[derive(Debug, Serialize, Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct ScanPrefixInput {
66 pub prefix: String,
67}
68
69#[derive(Debug, Serialize, Deserialize)]
70#[serde(deny_unknown_fields)]
71pub struct ScanKeysInput {
72 pub prefix: String,
73}
74
75#[derive(Debug, Serialize, Deserialize)]
76#[serde(deny_unknown_fields)]
77pub struct ScanEnforcementEventsInput {
78 #[serde(default)]
79 pub since_seq: u64,
80 #[serde(default = "default_until_seq")]
81 pub until_seq: u64,
82}
83
84#[derive(Debug, Serialize, Deserialize)]
85#[serde(deny_unknown_fields)]
86pub struct ScanEnforcementEventsSinceMsInput {
87 #[serde(default)]
88 pub since_ms: u64,
89 #[serde(default = "default_until_ms")]
90 pub until_ms: u64,
91}
92
93fn default_until_ms() -> u64 {
94 u64::MAX
95}
96
97fn default_until_seq() -> u64 {
98 u64::MAX
99}
100
101#[derive(Debug, Serialize, Deserialize)]
102#[serde(deny_unknown_fields)]
103pub struct HistoryInput {
104 pub key: String,
105 #[serde(default = "default_history_limit")]
106 pub limit: u64,
107}
108
109#[derive(Debug, Serialize, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct HistorySinceInput {
112 pub key: String,
113 pub since_ts: u64,
114 #[serde(default = "default_history_limit")]
115 pub limit: u64,
116}
117
118fn default_history_limit() -> u64 {
119 50
120}
121
122#[derive(Debug, Serialize, Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct SessionCheckConsultedInput {
125 pub key: String,
126}
127
128#[derive(Debug, Serialize, Deserialize)]
129#[serde(deny_unknown_fields)]
130pub struct SessionCheckConsultedRecentInput {
131 pub key: String,
132 #[serde(default = "default_ttl_secs")]
133 pub ttl_secs: u64,
134}
135
136fn default_ttl_secs() -> u64 {
137 900
138}
139
140#[derive(Debug, Serialize, Deserialize)]
141#[serde(deny_unknown_fields)]
142pub struct MemQueryInput {
143 pub query: String,
144 #[serde(default = "default_query_mode")]
145 pub mode: QueryMode,
146 #[serde(default = "default_query_limit")]
147 pub limit: u32,
148 /// Look-back window in days for time-scoped modes (`policy_activity`).
149 /// Ignored by other modes; `None` or `0` falls back to the mode's own
150 /// default, and values above the retention horizon are capped.
151 #[serde(default)]
152 pub since: Option<u64>,
153}
154
155fn default_query_mode() -> QueryMode {
156 QueryMode::Text
157}
158
159fn default_query_limit() -> u32 {
160 20
161}
162
163/// Search mode for mem_query.
164#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
165#[serde(rename_all = "snake_case")]
166pub enum QueryMode {
167 /// BM25 full-text search over record keys, values, and tags.
168 Text,
169 /// Filter records by tag (substring, case-insensitive).
170 Tag,
171 /// 1-hop graph traversal from a seed key.
172 Graph,
173 /// Confirmed gotchas whose `affected_files` fall under a path prefix.
174 /// `query` is a repo-relative directory or file path; empty returns nothing.
175 /// Resolves against the canonical `gotcha:*` records, not the search index.
176 DirGotchas,
177 /// Semantic search (requires --features semantic).
178 Semantic,
179 /// Shadow observations for local policies (`analytics:policy_shadow_*`).
180 /// `query` selects one policy slug; empty means all.
181 PolicyObservations,
182 /// Activity report for active local policies over a look-back window.
183 /// `query` selects one policy slug (empty = all); `since` sets the days.
184 PolicyActivity,
185 /// Raw local analytics records (`analytics:*`). `query` names the aggregate
186 /// by key substring (e.g. `miss_`) and is required — an empty query returns
187 /// nothing rather than dumping every record. Bounded by `limit`.
188 Analytics,
189}
190
191// ── B. Read-with-side-effect inputs ─────────────────────────────────────────
192
193#[derive(Debug, Serialize, Deserialize)]
194#[serde(deny_unknown_fields)]
195pub struct MemGetInput {
196 pub key: String,
197 /// Worktree (and, when a subagent, subagent) scope for the consultation
198 /// receipt this call mints. Server-populated by `mati serve` from its own
199 /// process cwd — never client-supplied through the public tool schema,
200 /// which exposes only `key`.
201 #[serde(default)]
202 pub actor: Option<String>,
203}
204
205#[derive(Debug, Serialize, Deserialize)]
206#[serde(deny_unknown_fields)]
207pub struct MemBootstrapInput {
208 #[serde(default)]
209 pub context_files: Vec<String>,
210}
211
212// ── C. Semantic mutation inputs ─────────────────────────────────────────────
213
214/// Gotcha creation/update input. The client expresses intent only — the daemon
215/// derives confidence, quality, timestamps, and version.
216///
217/// `confirmed` is the one exception, and it is honoured only for a
218/// developer-originated write (`source: "developer_manual"`). Everything else,
219/// including every `mem_set` from an agent, is forced to `false` and must go
220/// through `GotchaConfirm`.
221#[derive(Debug, Serialize, Deserialize)]
222#[serde(deny_unknown_fields)]
223pub struct GotchaDraftInput {
224 /// Gotcha key, must match `gotcha:<slug>`.
225 pub key: String,
226 /// Actionable rule text (imperative verb).
227 pub rule: String,
228 /// Causality sentence explaining why this rule exists.
229 pub reason: String,
230 /// Severity level.
231 pub severity: Severity,
232 /// File paths this gotcha applies to.
233 #[serde(default)]
234 pub affected_files: Vec<String>,
235 /// Optional external reference URL.
236 #[serde(default)]
237 pub ref_url: Option<String>,
238 /// Optional tags.
239 #[serde(default)]
240 pub tags: Vec<String>,
241 /// Record-level priority.
242 #[serde(default)]
243 pub priority: Priority,
244 /// Record source — when set, the handler uses this instead of defaulting
245 /// to `ClaudeEnrich`. CLI `gotcha add` sends `DeveloperManual` here.
246 #[serde(default)]
247 pub source: Option<String>,
248 /// Developer-asserted confirmation, honoured only alongside
249 /// `source: "developer_manual"`. `mem_set` never sets either field, so an
250 /// agent cannot mint an enforcing gotcha without the `confirm` action.
251 #[serde(default)]
252 pub confirmed: bool,
253}
254
255#[derive(Debug, Serialize, Deserialize)]
256#[serde(deny_unknown_fields)]
257pub struct GotchaConfirmInput {
258 pub key: String,
259 /// True when the confirm came from an in-session elicitation accept (the
260 /// developer approved a prompt showing the rule), false for a CLI or
261 /// direct-mode confirm. Drives the enforcement event's reason code so the
262 /// audit chain records the strongest confirm channel distinctly. Defaults
263 /// to false so an older client that omits it deserializes cleanly.
264 #[serde(default)]
265 pub via_elicitation: bool,
266}
267
268#[derive(Debug, Serialize, Deserialize)]
269#[serde(deny_unknown_fields)]
270pub struct GotchaTombstoneInput {
271 pub key: String,
272}
273
274#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
275#[serde(rename_all = "snake_case")]
276pub enum PolicyWriteOp {
277 Create,
278 Edit,
279 Enable,
280 Disable,
281 Stage,
282 Delete,
283}
284
285#[derive(Debug, Serialize, Deserialize)]
286#[serde(deny_unknown_fields)]
287pub struct PolicyWriteInput {
288 pub op: PolicyWriteOp,
289 pub key: String,
290 #[serde(default)]
291 pub policy: Option<PolicyRecord>,
292 #[serde(default)]
293 pub stage: Option<StorePolicyStage>,
294}
295
296/// File enrichment input from LLM analysis (e.g., /mati-enrich workflow).
297/// The file record must already exist (created by init/reparse).
298///
299/// Fields that are daemon-managed and MUST NOT appear:
300/// - `gotcha_keys` (managed by gotcha lifecycle commands)
301/// - `imports` (derived from tree-sitter)
302/// - All structural/internal fields (unsafe_count, unwrap_count, etc.)
303#[derive(Debug, Serialize, Deserialize)]
304#[serde(deny_unknown_fields)]
305pub struct FileEnrichInput {
306 /// File path (maps to `file:<path>`).
307 pub path: String,
308 /// Purpose sentence (verb-led).
309 pub purpose: String,
310 /// Function/method entry points identified by enrichment.
311 #[serde(default)]
312 pub entry_points: Vec<String>,
313 /// Decision records that affect this file.
314 #[serde(default)]
315 pub decision_keys: Vec<String>,
316 /// TODO items found during enrichment.
317 #[serde(default)]
318 pub todos: Vec<String>,
319 /// Optional tags.
320 #[serde(default)]
321 pub tags: Vec<String>,
322 /// Record-level priority.
323 #[serde(default)]
324 pub priority: Priority,
325}
326
327#[derive(Debug, Serialize, Deserialize)]
328#[serde(deny_unknown_fields)]
329pub struct FileReparseInput {
330 pub path: String,
331}
332
333#[derive(Debug, Serialize, Deserialize)]
334#[serde(deny_unknown_fields)]
335pub struct FileEditHookInput {
336 pub path: String,
337}
338
339/// Path-only doc capture. The daemon reads the file from disk and extracts
340/// the doc comment — no content crosses the wire.
341#[derive(Debug, Serialize, Deserialize)]
342#[serde(deny_unknown_fields)]
343pub struct DocCaptureInput {
344 pub path: String,
345}
346
347#[derive(Debug, Serialize, Deserialize)]
348#[serde(deny_unknown_fields)]
349pub struct DecisionUpsertInput {
350 /// Key slug (daemon prepends `decision:`).
351 pub slug: String,
352 /// Human-readable summary ("We use X because Y").
353 pub value: String,
354 /// Concise decision summary (payload field).
355 pub summary: String,
356 /// Rationale text (payload field).
357 pub rationale: String,
358 /// Optional tags.
359 #[serde(default)]
360 pub tags: Vec<String>,
361 /// Record-level priority.
362 #[serde(default)]
363 pub priority: Priority,
364}
365
366#[derive(Debug, Serialize, Deserialize)]
367#[serde(deny_unknown_fields)]
368pub struct DevNoteUpsertInput {
369 /// If absent, daemon auto-generates `dev_note:<slug>-<timestamp>`.
370 /// If present, must match an existing `dev_note:*` key (update mode).
371 #[serde(default)]
372 pub key: Option<String>,
373 /// Freeform note text.
374 pub text: String,
375 /// Optional tags.
376 #[serde(default)]
377 pub tags: Vec<String>,
378 /// Record-level priority.
379 #[serde(default)]
380 pub priority: Priority,
381}
382
383#[derive(Debug, Serialize, Deserialize)]
384#[serde(deny_unknown_fields)]
385pub struct SessionLogInput {
386 /// The event type (closed enum, 14 variants).
387 pub event: SessionEvent,
388 /// The record key this event pertains to.
389 pub key: String,
390 /// The AI agent session (Claude Code `session_id`) that triggered this event,
391 /// for per-actor audit attribution (schema_version 2). Optional — absent for
392 /// older clients and agents that provide no session.
393 #[serde(default)]
394 pub session_id: Option<String>,
395 /// Receipt scope this event was decided at — the subagent `agent_id`, or
396 /// absent for the main thread. The daemon needs it to look up the receipt
397 /// that authorized an allow, since receipts are actor-scoped.
398 #[serde(default)]
399 pub actor: Option<String>,
400 /// SHA-256 of the gotcha state the hook decided on, for the enforcement
401 /// event's `decision_basis_hash`. Computed hook-side because that is where
402 /// the decision was made; a digest, never raw tool input.
403 #[serde(default)]
404 pub decision_basis_hash: Option<String>,
405}
406
407/// Exact payload received from Claude Code's InstructionsLoaded hook.
408#[derive(Debug, Serialize, Deserialize)]
409#[serde(deny_unknown_fields)]
410pub struct InstructionsLoadedInput {
411 pub payload: crate::hooks::decide::InstructionsLoadedPayload,
412}
413
414#[derive(Debug, Serialize, Deserialize)]
415#[serde(deny_unknown_fields)]
416pub struct PolicyShadowObserveInput {
417 pub policy_key: String,
418 pub action: Action,
419 pub would: crate::hooks::decide::ShadowOutcome,
420}
421
422/// Session analytics event types. Each maps to a daily aggregation key prefix.
423///
424/// `Hit` is NOT included — it has richer side effects and uses the separate
425/// `ConsultationHit` command.
426#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
427#[serde(rename_all = "snake_case")]
428pub enum SessionEvent {
429 Miss,
430 ComplianceMiss,
431 ComplianceHit,
432 /// Claude edit gate: an edit DEFERRED because a recent consultation receipt
433 /// exists. Records `AllowAfterReceipt` with an edit-specific reason code, so
434 /// the audit trail proves the edit (not just the read) was preceded by a
435 /// consult (Plane 2 evidence).
436 EditConsulted,
437 /// Claude edit gate: an edit was DENIED (stale or shell-evaded — no recent
438 /// consult). Records `Deny` with an edit-specific reason code.
439 EditBlocked,
440 /// Enterprise floor mandate: an unconsulted access to a consult-required path was DENIED.
441 /// Records `Deny` with reason `floor_consult_required` (distinct from gotcha denies).
442 FloorConsultMiss,
443 /// Local policy denied an unconsulted governed action.
444 PolicyConsultMiss,
445 /// Local policy allowed an action after its receipt was present.
446 PolicyConsultHit,
447 /// Local policy injected steering context without changing the decision.
448 PolicySteered,
449 CodexShellMiss,
450 /// Codex PRE-hook (`codex-pre-bash` / `codex-pre-apply-patch`) BLOCKED an
451 /// unconsulted access — the shell command or patch never ran. Records
452 /// `Deny`, unlike [`SessionEvent::CodexShellMiss`], which the POST-bash
453 /// hook fires after the fact when nothing was denied.
454 CodexShellBlocked,
455 /// An unclassified command mentioned a meaningful literal from an active
456 /// policy trigger; records a bypass signal without changing the decision.
457 UnclassifiedPolicyLiteralBypass,
458 Bootstrap,
459 PromptNudge,
460 /// Post-bash observed a command that passed `is_schema_introspection` but
461 /// whose leading word did not classify as `db_client` — an upstream
462 /// PreToolUse hook rewrote it (observed live: `rtk psql …`). The
463 /// consultation receipt this command should mint never mints, so a
464 /// `db_client` policy stays permanently unsatisfiable: a deadlock, not a
465 /// bypass. Diagnostic only; does not change any decision.
466 WrappedDbClientMiss,
467 /// The `FileDeleted` tombstone bypass fired on a caller-confirmed
468 /// deletion, suppressing a deny a qualifying confirmed gotcha would
469 /// otherwise have produced. Records `BypassDetected` — the decision
470 /// stayed `Tombstone` (allow), but the suppression is enforcement-
471 /// relevant and must reach the hash-chained log.
472 TombstoneBypassedDeny,
473}
474
475#[derive(Debug, Serialize, Deserialize)]
476#[serde(deny_unknown_fields)]
477pub struct ConsultationHitInput {
478 pub key: String,
479 /// Capture a content fingerprint when the target record exists.
480 #[serde(default = "default_capture_fingerprint")]
481 pub capture_fingerprint: bool,
482 #[serde(default)]
483 pub actor: Option<String>,
484 /// Claude session_id (the session) — for ReceiptMinted audit attribution.
485 #[serde(default)]
486 pub session_id: Option<String>,
487 /// Subagent agent_id when present (fallback attribution).
488 #[serde(default)]
489 pub agent_id: Option<String>,
490 /// How the consultation happened, recorded onto the receipt. `None` when
491 /// the caller's path is unattributed — see
492 /// `store::session::ConsultationReceipt::source`. Defaulted so an older
493 /// hook binary talking to a newer daemon still mints.
494 #[serde(default)]
495 pub source: Option<crate::store::ReceiptSource>,
496 /// SHA-256 of the gotcha state in force when the receipt was minted.
497 #[serde(default)]
498 pub decision_basis_hash: Option<String>,
499}
500
501fn default_capture_fingerprint() -> bool {
502 true
503}
504
505/// Input for `Command::SubagentHarvest`. Written by the `SubagentStop` hook
506/// when a Task subagent finishes: its `last_assistant_message` is the subagent's
507/// own prose summary, captured into `session:summary:latest` so `mem_bootstrap`
508/// can surface it as `recent_session`. All fields but `summary` default, so an
509/// older hook binary talking to a newer daemon still writes.
510#[derive(Debug, Serialize, Deserialize)]
511#[serde(deny_unknown_fields)]
512pub struct SubagentHarvestInput {
513 /// The subagent's final assistant message — its own summary of the work.
514 pub summary: String,
515 /// Claude session_id the subagent ran under (the spawning session).
516 #[serde(default)]
517 pub session_id: Option<String>,
518 /// The subagent's own agent_id.
519 #[serde(default)]
520 pub agent_id: Option<String>,
521 /// Subagent type (e.g. "general-purpose").
522 #[serde(default)]
523 pub agent_type: Option<String>,
524 /// Path to the subagent's transcript, for later retrieval.
525 #[serde(default)]
526 pub transcript_path: Option<String>,
527}
528
529/// Input for `Command::SubagentSpawned`. Written by the `SubagentStart` hook:
530/// records a subagent's presence as a hash-chained enforcement event so the audit
531/// can attribute a subagent that spawned and never consulted. `agent_id` is
532/// required in practice (the presence is meaningless without it); the recorder
533/// no-ops when it is absent. All fields default for hook/daemon version skew.
534#[derive(Debug, Serialize, Deserialize)]
535#[serde(deny_unknown_fields)]
536pub struct SubagentSpawnedInput {
537 /// The spawned subagent's own agent_id.
538 #[serde(default)]
539 pub agent_id: Option<String>,
540 /// The spawning session_id.
541 #[serde(default)]
542 pub session_id: Option<String>,
543 /// Subagent type (e.g. "general-purpose"), preserved for audit scoring.
544 #[serde(default)]
545 pub agent_type: Option<String>,
546}
547
548/// Input for `Command::SubagentEdge`. Written by the `Agent`-tool `PostToolUse`
549/// hook when one subagent spawns another: records the parent→child spawn edge as
550/// a hash-chained enforcement event so the audit can walk the tree past the leaf.
551/// `child_agent_id` (the spawned subagent) and `parent_agent_id` (its spawner)
552/// are both required in practice; the recorder no-ops when either is absent —
553/// a root-session spawn (no parent) is already covered by `SubagentSpawned`. All
554/// fields default for hook/daemon version skew.
555#[derive(Debug, Serialize, Deserialize)]
556#[serde(deny_unknown_fields)]
557pub struct SubagentEdgeInput {
558 /// The spawned child subagent's own agent_id (`tool_response.agentId`).
559 #[serde(default)]
560 pub child_agent_id: Option<String>,
561 /// The spawning parent subagent's agent_id (top-level `agent_id`).
562 #[serde(default)]
563 pub parent_agent_id: Option<String>,
564 /// The shared session_id.
565 #[serde(default)]
566 pub session_id: Option<String>,
567 /// Child subagent type (e.g. "general-purpose"), preserved for audit scoring.
568 #[serde(default)]
569 pub agent_type: Option<String>,
570}
571
572/// Input for `Command::RecordImport`. Records are written verbatim into the
573/// knowledge tree, preserving every field. The daemon validates each record's
574/// key prefix against the knowledge-namespace allowlist before writing.
575#[derive(Debug, Serialize, Deserialize)]
576#[serde(deny_unknown_fields)]
577pub struct RecordImportInput {
578 pub records: Vec<crate::store::Record>,
579}
580
581/// Input for `Command::ConfigGet`. `key` is the dotted config name
582/// (e.g. `audit.write_durability`, `enforcement.retention`).
583#[derive(Debug, Serialize, Deserialize)]
584#[serde(deny_unknown_fields)]
585pub struct ConfigGetInput {
586 pub key: String,
587}
588
589/// Input for `Command::ConfigSet`. Values are always sent as strings on the
590/// wire and parsed/validated by the dispatcher.
591#[derive(Debug, Serialize, Deserialize)]
592#[serde(deny_unknown_fields)]
593pub struct ConfigSetInput {
594 pub key: String,
595 pub value: String,
596}
597
598/// Input for `Command::SandboxAudit`. The dispatcher records an
599/// `EnforcementConfigChanged` event verbatim from these fields.
600#[derive(Debug, Serialize, Deserialize)]
601#[serde(deny_unknown_fields)]
602pub struct SandboxAuditInput {
603 pub setting: String,
604 /// Value mati expected before the observed configuration change. Older
605 /// clients may omit this because sandbox audits historically had no
606 /// before-value; the guard supplies it for ConfigChange events.
607 #[serde(default)]
608 pub old_value: String,
609 pub new_value: String,
610 pub reason: String,
611}
612
613// ── Shared enums ────────────────────────────────────────────────────────────
614
615/// Severity level for gotcha records. Closed enum.
616#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
617#[serde(rename_all = "snake_case")]
618pub enum Severity {
619 Critical,
620 High,
621 #[default]
622 Normal,
623 Low,
624}
625
626/// Record-level priority. Closed enum.
627#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
628#[serde(rename_all = "snake_case")]
629pub enum Priority {
630 Critical,
631 High,
632 #[default]
633 Normal,
634 Low,
635}
636
637// ── Conversions from store types ────────────────────────────────────────────
638
639impl From<crate::store::Priority> for Severity {
640 fn from(p: crate::store::Priority) -> Self {
641 match p {
642 crate::store::Priority::Low => Severity::Low,
643 crate::store::Priority::Normal => Severity::Normal,
644 crate::store::Priority::High => Severity::High,
645 crate::store::Priority::Critical => Severity::Critical,
646 }
647 }
648}
649
650impl From<crate::store::Priority> for Priority {
651 fn from(p: crate::store::Priority) -> Self {
652 match p {
653 crate::store::Priority::Low => Priority::Low,
654 crate::store::Priority::Normal => Priority::Normal,
655 crate::store::Priority::High => Priority::High,
656 crate::store::Priority::Critical => Priority::Critical,
657 }
658 }
659}
660
661// ── Command helpers ──────────────────────────────────────────────────────────