Skip to main content

meerkat_workgraph/
tools.rs

1use serde::de::DeserializeOwned;
2use serde::{Deserialize, Serialize};
3use serde_json::{Value, json};
4use strum::IntoEnumIterator;
5
6use crate::store::WorkGraphEventFilter;
7use crate::types::{
8    AddEvidenceRequest, AttentionReassignRequest, ClaimWorkItemRequest, CloseWorkItemRequest,
9    LinkWorkItemsRequest, PolicyEscalateRequest, ReadyWorkFilter, ReleaseWorkItemRequest,
10    UpdateWorkItemRequest, WorkGraphSnapshotFilter, WorkItemFilter, WorkItemId, WorkNamespace,
11};
12use crate::{CreateWorkItemRequest, WorkGraphError, WorkGraphService};
13
14/// Typed tool-facing error class for WorkGraph operations.
15///
16/// This is the closed set of semantic error outcomes a WorkGraph tool call can
17/// surface. It is derived directly from [`WorkGraphError`] (the canonical domain
18/// error) in [`map_error`], never re-parsed from text, and serializes to the
19/// stable `snake_case` wire codes consumed by SDKs. Surfaces map this typed code
20/// onto their own transport numbering (e.g. JSON-RPC) with an exhaustive match.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum WorkGraphToolErrorCode {
24    InvalidArguments,
25    NotFound,
26    CapabilityUnavailable,
27    Conflict,
28    InvalidTransition,
29    StoreError,
30    InternalError,
31}
32
33impl WorkGraphToolErrorCode {
34    /// Stable wire/display token for this error class (matches the `snake_case`
35    /// serde representation). For human-readable messages and logs only — never
36    /// parse this back into a decision.
37    pub const fn as_str(self) -> &'static str {
38        match self {
39            Self::InvalidArguments => "invalid_arguments",
40            Self::NotFound => "not_found",
41            Self::CapabilityUnavailable => "capability_unavailable",
42            Self::Conflict => "conflict",
43            Self::InvalidTransition => "invalid_transition",
44            Self::StoreError => "store_error",
45            Self::InternalError => "internal_error",
46        }
47    }
48}
49
50impl std::fmt::Display for WorkGraphToolErrorCode {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        f.write_str(self.as_str())
53    }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57pub struct WorkGraphToolError {
58    pub code: WorkGraphToolErrorCode,
59    pub message: String,
60}
61
62impl WorkGraphToolError {
63    fn new(code: WorkGraphToolErrorCode, message: impl Into<String>) -> Self {
64        Self {
65            code,
66            message: message.into(),
67        }
68    }
69}
70
71/// Closed catalog of WorkGraph tool operations.
72///
73/// The iteration order of [`strum::EnumIter`] is declaration order, so the
74/// variant list below IS the catalog — there is no parallel hand-maintained
75/// `ALL` slice to drift. Adding a variant automatically extends the advertised
76/// tool list and the dispatch surface, and the compiler forces the exhaustive
77/// `name()`/`description()`/`schema()` matches (and the dispatch match in
78/// [`handle_workgraph_tools_call`]) to acknowledge it.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::EnumIter)]
80enum WorkGraphToolContract {
81    Create,
82    Get,
83    List,
84    Ready,
85    Snapshot,
86    Events,
87    Claim,
88    Release,
89    Update,
90    PolicyEscalate,
91    Block,
92    Close,
93    Link,
94    AddEvidence,
95    AttentionReassign,
96}
97
98impl WorkGraphToolContract {
99    const fn name(self) -> &'static str {
100        match self {
101            Self::Create => "workgraph_create",
102            Self::Get => "workgraph_get",
103            Self::List => "workgraph_list",
104            Self::Ready => "workgraph_ready",
105            Self::Snapshot => "workgraph_snapshot",
106            Self::Events => "workgraph_events",
107            Self::Claim => "workgraph_claim",
108            Self::Release => "workgraph_release",
109            Self::Update => "workgraph_update",
110            Self::PolicyEscalate => "workgraph_policy_escalate",
111            Self::Block => "workgraph_block",
112            Self::Close => "workgraph_close",
113            Self::Link => "workgraph_link",
114            Self::AddEvidence => "workgraph_add_evidence",
115            Self::AttentionReassign => "workgraph_attention_reassign",
116        }
117    }
118
119    const fn description(self) -> &'static str {
120        match self {
121            Self::Create => "Create a durable WorkGraph item.",
122            Self::Get => "Read one WorkGraph item.",
123            Self::List => "List WorkGraph items.",
124            Self::Ready => "List ready, claimable WorkGraph items.",
125            Self::Snapshot => "Read a WorkGraph observability snapshot.",
126            Self::Events => "Read WorkGraph event history.",
127            Self::Claim => "Claim a ready WorkGraph item with CAS revision checking.",
128            Self::Release => "Release a claimed WorkGraph item.",
129            Self::Update => "Update non-terminal WorkGraph item fields.",
130            Self::PolicyEscalate => "Monotonically tighten a WorkGraph completion policy.",
131            Self::Block => "Mark a WorkGraph item blocked.",
132            Self::Close => "Close a WorkGraph item with a terminal status.",
133            Self::Link => "Create a dependency or relationship edge.",
134            Self::AddEvidence => "Attach a typed evidence reference to a WorkGraph item.",
135            Self::AttentionReassign => "Reassign a WorkGraph attention binding.",
136        }
137    }
138
139    fn schema(self) -> Value {
140        match self {
141            Self::Create => create_schema(),
142            Self::Get => id_schema(false),
143            Self::List => list_schema(),
144            Self::Ready => ready_schema(),
145            Self::Snapshot => snapshot_schema(),
146            Self::Events => events_schema(),
147            Self::Claim => claim_schema(),
148            Self::Release | Self::Block => revision_id_schema(),
149            Self::Update => update_schema(),
150            Self::PolicyEscalate => policy_escalate_schema(),
151            Self::Close => close_schema(),
152            Self::Link => link_schema(),
153            Self::AddEvidence => evidence_schema(),
154            Self::AttentionReassign => attention_reassign_schema(),
155        }
156    }
157
158    const fn is_unscoped_surface_allowed(self) -> bool {
159        !matches!(self, Self::AttentionReassign | Self::PolicyEscalate)
160    }
161
162    fn parse(name: &str) -> Result<Self, WorkGraphToolError> {
163        Self::iter()
164            .find(|contract| contract.name() == name)
165            .ok_or_else(|| {
166                WorkGraphToolError::new(
167                    WorkGraphToolErrorCode::NotFound,
168                    format!("unknown WorkGraph tool '{name}'"),
169                )
170            })
171    }
172}
173
174pub fn workgraph_tools_list() -> Vec<Value> {
175    WorkGraphToolContract::iter()
176        .map(|contract| tool(contract.name(), contract.description(), contract.schema()))
177        .collect()
178}
179
180/// Public WorkGraph MCP/default surface.
181///
182/// Attention-only operations require a runtime-injected attention projection
183/// witness before dispatch, so they are advertised only by
184/// [`WorkGraphToolSurface::with_attention_projection`](crate::WorkGraphToolSurface::with_attention_projection)
185/// and rejected by [`handle_unscoped_workgraph_tools_call`].
186pub fn unscoped_workgraph_tools_list() -> Vec<Value> {
187    WorkGraphToolContract::iter()
188        .filter(|contract| contract.is_unscoped_surface_allowed())
189        .map(|contract| tool(contract.name(), contract.description(), contract.schema()))
190        .collect()
191}
192
193pub async fn handle_unscoped_workgraph_tools_call(
194    service: &WorkGraphService,
195    name: &str,
196    arguments: &Value,
197) -> Result<Value, WorkGraphToolError> {
198    let contract = WorkGraphToolContract::parse(name)?;
199    if !contract.is_unscoped_surface_allowed() {
200        return Err(WorkGraphToolError::new(
201            WorkGraphToolErrorCode::NotFound,
202            format!("unknown WorkGraph tool '{name}'"),
203        ));
204    }
205    handle_workgraph_tools_call(service, name, arguments).await
206}
207
208pub async fn handle_workgraph_tools_call(
209    service: &WorkGraphService,
210    name: &str,
211    arguments: &Value,
212) -> Result<Value, WorkGraphToolError> {
213    match WorkGraphToolContract::parse(name)? {
214        WorkGraphToolContract::Create => {
215            let request: CreateWorkItemRequest = parse(arguments)?;
216            service
217                .create(request)
218                .await
219                .map(|item| json!({ "item": item }))
220                .map_err(map_error)
221        }
222        WorkGraphToolContract::Get => {
223            let request: IdParams = parse(arguments)?;
224            service
225                .get(request.realm_id, request.namespace, request.id)
226                .await
227                .map(|item| json!({ "item": item }))
228                .map_err(map_error)
229        }
230        WorkGraphToolContract::List => {
231            let filter: WorkItemFilter = parse(arguments)?;
232            service
233                .list(filter)
234                .await
235                .map(|items| json!({ "items": items }))
236                .map_err(map_error)
237        }
238        WorkGraphToolContract::Ready => {
239            let filter: ReadyWorkFilter = parse(arguments)?;
240            service
241                .ready(filter)
242                .await
243                .map(|items| json!({ "items": items }))
244                .map_err(map_error)
245        }
246        WorkGraphToolContract::Snapshot => {
247            let filter: WorkGraphSnapshotFilter = parse(arguments)?;
248            service
249                .snapshot(filter)
250                .await
251                .map(|snapshot| json!({ "snapshot": snapshot }))
252                .map_err(map_error)
253        }
254        WorkGraphToolContract::Claim => {
255            let request: ClaimWorkItemRequest = parse(arguments)?;
256            service
257                .claim(request)
258                .await
259                .map(|item| json!({ "item": item }))
260                .map_err(map_error)
261        }
262        WorkGraphToolContract::Release => {
263            let request: ReleaseWorkItemRequest = parse(arguments)?;
264            service
265                .release(request)
266                .await
267                .map(|item| json!({ "item": item }))
268                .map_err(map_error)
269        }
270        WorkGraphToolContract::Update => {
271            let request: UpdateWorkItemRequest = parse(arguments)?;
272            service
273                .update(request)
274                .await
275                .map(|item| json!({ "item": item }))
276                .map_err(map_error)
277        }
278        WorkGraphToolContract::PolicyEscalate => {
279            let request: PolicyEscalateRequest = parse(arguments)?;
280            service
281                .escalate_policy(request)
282                .await
283                .map(|item| json!({ "item": item }))
284                .map_err(map_error)
285        }
286        WorkGraphToolContract::Block => {
287            let request: RevisionIdParams = parse(arguments)?;
288            service
289                .block(
290                    request.realm_id,
291                    request.namespace,
292                    request.id,
293                    request.expected_revision,
294                )
295                .await
296                .map(|item| json!({ "item": item }))
297                .map_err(map_error)
298        }
299        WorkGraphToolContract::Close => {
300            let request: CloseWorkItemRequest = parse(arguments)?;
301            service
302                .close(request)
303                .await
304                .map(|item| json!({ "item": item }))
305                .map_err(map_error)
306        }
307        WorkGraphToolContract::Link => {
308            let request: LinkWorkItemsRequest = parse(arguments)?;
309            service
310                .link(request)
311                .await
312                .map(|edge| json!({ "edge": edge }))
313                .map_err(map_error)
314        }
315        WorkGraphToolContract::AddEvidence => {
316            let request: AddEvidenceRequest = parse(arguments)?;
317            service
318                .add_evidence(request)
319                .await
320                .map(|item| json!({ "item": item }))
321                .map_err(map_error)
322        }
323        WorkGraphToolContract::AttentionReassign => {
324            let request: AttentionReassignRequest = parse(arguments)?;
325            service
326                .reassign_attention(request)
327                .await
328                .map(|result| json!({ "previous": result.previous, "attention": result.attention }))
329                .map_err(map_error)
330        }
331        WorkGraphToolContract::Events => {
332            let filter: WorkGraphEventFilterParams = parse(arguments)?;
333            service
334                .events(filter.into())
335                .await
336                .map(|events| json!({ "events": events }))
337                .map_err(map_error)
338        }
339    }
340}
341
342#[derive(Debug, Deserialize)]
343struct IdParams {
344    id: WorkItemId,
345    #[serde(default)]
346    realm_id: Option<String>,
347    #[serde(default)]
348    namespace: Option<WorkNamespace>,
349}
350
351#[derive(Debug, Deserialize)]
352struct RevisionIdParams {
353    id: WorkItemId,
354    expected_revision: u64,
355    #[serde(default)]
356    realm_id: Option<String>,
357    #[serde(default)]
358    namespace: Option<WorkNamespace>,
359}
360
361#[derive(Debug, Deserialize)]
362struct WorkGraphEventFilterParams {
363    #[serde(default)]
364    realm_id: Option<String>,
365    #[serde(default)]
366    namespace: Option<WorkNamespace>,
367    #[serde(default)]
368    all_namespaces: bool,
369    #[serde(default)]
370    after_seq: Option<i64>,
371    #[serde(default)]
372    limit: Option<usize>,
373}
374
375impl From<WorkGraphEventFilterParams> for WorkGraphEventFilter {
376    fn from(value: WorkGraphEventFilterParams) -> Self {
377        Self {
378            realm_id: value.realm_id,
379            namespace: value.namespace,
380            all_namespaces: value.all_namespaces,
381            after_seq: value.after_seq,
382            limit: value.limit,
383        }
384    }
385}
386
387fn parse<T: DeserializeOwned>(arguments: &Value) -> Result<T, WorkGraphToolError> {
388    serde_json::from_value(arguments.clone()).map_err(|err| {
389        WorkGraphToolError::new(
390            WorkGraphToolErrorCode::InvalidArguments,
391            format!("invalid WorkGraph arguments: {err}"),
392        )
393    })
394}
395
396fn map_error(error: WorkGraphError) -> WorkGraphToolError {
397    let code = match error {
398        WorkGraphError::NotFound { .. } | WorkGraphError::AttentionNotFound { .. } => {
399            WorkGraphToolErrorCode::NotFound
400        }
401        WorkGraphError::StaleRevision { .. } | WorkGraphError::Conflict(_) => {
402            WorkGraphToolErrorCode::Conflict
403        }
404        WorkGraphError::InvalidTransition(_) => WorkGraphToolErrorCode::InvalidTransition,
405        WorkGraphError::InvalidInput(_) | WorkGraphError::InvalidTimestampMillis { .. } => {
406            WorkGraphToolErrorCode::InvalidArguments
407        }
408        WorkGraphError::UnsupportedBackend(_) => WorkGraphToolErrorCode::CapabilityUnavailable,
409        WorkGraphError::Store(_) => WorkGraphToolErrorCode::StoreError,
410    };
411    WorkGraphToolError::new(code, error.to_string())
412}
413
414fn tool(name: &str, description: &str, schema: Value) -> Value {
415    json!({
416        "name": name,
417        "description": description,
418        "inputSchema": schema,
419    })
420}
421
422fn base_properties() -> serde_json::Map<String, Value> {
423    serde_json::Map::from_iter([
424        ("realm_id".to_string(), json!({ "type": "string" })),
425        ("namespace".to_string(), json!({ "type": "string" })),
426    ])
427}
428
429fn external_ref_schema() -> Value {
430    json!({
431        "type": "object",
432        "properties": {
433            "kind": { "type": "string" },
434            "id": { "type": "string" },
435            "url": { "type": "string" }
436        },
437        "required": ["kind", "id"],
438        "additionalProperties": false
439    })
440}
441
442fn evidence_ref_schema() -> Value {
443    json!({
444        "type": "object",
445        "properties": {
446            "kind": { "type": "string" },
447            "id": { "type": "string" },
448            "label": { "type": "string" },
449            "summary": { "type": "string" }
450        },
451        "required": ["kind", "id"],
452        "additionalProperties": false
453    })
454}
455
456fn owner_key_schema() -> Value {
457    json!({
458        "type": "object",
459        "properties": {
460            "kind": {
461                "type": "string",
462                "enum": ["principal", "agent", "session", "mob", "label"]
463            },
464            "id": { "type": "string" }
465        },
466        "required": ["kind", "id"],
467        "additionalProperties": false
468    })
469}
470
471fn goal_attention_target_schema() -> Value {
472    json!({
473        "oneOf": [
474            {
475                "type": "object",
476                "properties": {
477                    "kind": { "const": "session" },
478                    "session_id": { "type": "string" }
479                },
480                "required": ["kind", "session_id"],
481                "additionalProperties": false
482            },
483            {
484                "type": "object",
485                "properties": {
486                    "kind": { "const": "owner" },
487                    "owner_key": owner_key_schema()
488                },
489                "required": ["kind", "owner_key"],
490                "additionalProperties": false
491            }
492        ]
493    })
494}
495
496fn object(properties: serde_json::Map<String, Value>, required: &[&str]) -> Value {
497    json!({
498        "type": "object",
499        "properties": properties,
500        "required": required,
501        "additionalProperties": false,
502    })
503}
504
505fn id_schema(include_revision: bool) -> Value {
506    let mut properties = base_properties();
507    properties.insert("id".to_string(), json!({ "type": "string" }));
508    if include_revision {
509        properties.insert(
510            "expected_revision".to_string(),
511            json!({ "type": "integer", "minimum": 0 }),
512        );
513        object(properties, &["id", "expected_revision"])
514    } else {
515        object(properties, &["id"])
516    }
517}
518
519fn revision_id_schema() -> Value {
520    id_schema(true)
521}
522
523fn attention_reassign_schema() -> Value {
524    let mut properties = base_properties();
525    properties.extend([
526        ("binding_id".to_string(), json!({ "type": "string" })),
527        (
528            "expected_revision".to_string(),
529            json!({ "type": "integer", "minimum": 0 }),
530        ),
531        ("target".to_string(), goal_attention_target_schema()),
532    ]);
533    object(properties, &["binding_id", "expected_revision", "target"])
534}
535
536fn create_schema() -> Value {
537    let mut properties = base_properties();
538    properties.extend([
539        ("title".to_string(), json!({ "type": "string" })),
540        ("description".to_string(), json!({ "type": "string" })),
541        (
542            "priority".to_string(),
543            json!({ "type": "string", "enum": ["low", "medium", "high"] }),
544        ),
545        (
546            "labels".to_string(),
547            json!({ "type": "array", "items": { "type": "string" } }),
548        ),
549        (
550            "due_at".to_string(),
551            json!({ "type": "string", "format": "date-time" }),
552        ),
553        (
554            "not_before".to_string(),
555            json!({ "type": "string", "format": "date-time" }),
556        ),
557        (
558            "snoozed_until".to_string(),
559            json!({ "type": "string", "format": "date-time" }),
560        ),
561        (
562            "status".to_string(),
563            json!({ "type": "string", "enum": ["open", "blocked"] }),
564        ),
565        (
566            "external_refs".to_string(),
567            json!({ "type": "array", "items": external_ref_schema() }),
568        ),
569        (
570            "evidence_refs".to_string(),
571            json!({ "type": "array", "items": evidence_ref_schema() }),
572        ),
573    ]);
574    object(properties, &["title"])
575}
576
577fn list_schema() -> Value {
578    let mut properties = base_properties();
579    properties.extend([
580        ("all_namespaces".to_string(), json!({ "type": "boolean" })),
581        (
582            "statuses".to_string(),
583            json!({ "type": "array", "items": { "type": "string" } }),
584        ),
585        (
586            "labels".to_string(),
587            json!({ "type": "array", "items": { "type": "string" } }),
588        ),
589        ("include_terminal".to_string(), json!({ "type": "boolean" })),
590        (
591            "limit".to_string(),
592            json!({ "type": "integer", "minimum": 1 }),
593        ),
594    ]);
595    object(properties, &[])
596}
597
598fn ready_schema() -> Value {
599    let mut properties = base_properties();
600    properties.extend([
601        (
602            "labels".to_string(),
603            json!({ "type": "array", "items": { "type": "string" } }),
604        ),
605        (
606            "limit".to_string(),
607            json!({ "type": "integer", "minimum": 1 }),
608        ),
609    ]);
610    object(properties, &[])
611}
612
613fn snapshot_schema() -> Value {
614    list_schema()
615}
616
617fn events_schema() -> Value {
618    let mut properties = base_properties();
619    properties.extend([
620        ("all_namespaces".to_string(), json!({ "type": "boolean" })),
621        (
622            "after_seq".to_string(),
623            json!({ "type": "integer", "minimum": 0 }),
624        ),
625        (
626            "limit".to_string(),
627            json!({ "type": "integer", "minimum": 1 }),
628        ),
629    ]);
630    object(properties, &[])
631}
632
633fn claim_schema() -> Value {
634    let mut properties = base_properties();
635    properties.extend([
636        ("id".to_string(), json!({ "type": "string" })),
637        (
638            "expected_revision".to_string(),
639            json!({ "type": "integer", "minimum": 0 }),
640        ),
641        (
642            "owner".to_string(),
643            json!({
644                "type": "object",
645                "properties": {
646                    "key": {
647                        "type": "object",
648                        "properties": {
649                            "kind": {
650                                "type": "string",
651                                "enum": ["principal", "agent", "session", "mob", "label"]
652                            },
653                            "id": { "type": "string" }
654                        },
655                        "required": ["kind", "id"],
656                        "additionalProperties": false
657                    },
658                    "display_name": { "type": "string" }
659                },
660                "required": ["key"],
661                "additionalProperties": false
662            }),
663        ),
664        (
665            "lease_seconds".to_string(),
666            json!({ "type": "integer", "minimum": 1 }),
667        ),
668        (
669            "lease_expires_at".to_string(),
670            json!({ "type": "string", "format": "date-time" }),
671        ),
672    ]);
673    object(properties, &["id", "expected_revision", "owner"])
674}
675
676fn update_schema() -> Value {
677    let mut properties = base_properties();
678    properties.extend([
679        ("id".to_string(), json!({ "type": "string" })),
680        (
681            "expected_revision".to_string(),
682            json!({ "type": "integer", "minimum": 0 }),
683        ),
684        ("title".to_string(), json!({ "type": "string" })),
685        ("description".to_string(), json!({ "type": "string" })),
686        (
687            "priority".to_string(),
688            json!({ "type": "string", "enum": ["low", "medium", "high"] }),
689        ),
690        (
691            "labels".to_string(),
692            json!({ "type": "array", "items": { "type": "string" } }),
693        ),
694        (
695            "due_at".to_string(),
696            json!({ "type": "string", "format": "date-time" }),
697        ),
698        (
699            "not_before".to_string(),
700            json!({ "type": "string", "format": "date-time" }),
701        ),
702        (
703            "snoozed_until".to_string(),
704            json!({ "type": "string", "format": "date-time" }),
705        ),
706        (
707            "external_refs".to_string(),
708            json!({ "type": "array", "items": external_ref_schema() }),
709        ),
710    ]);
711    object(properties, &["id", "expected_revision"])
712}
713
714fn completion_policy_schema() -> Value {
715    json!({
716        "oneOf": [
717            {
718                "type": "object",
719                "properties": { "kind": { "const": "self_attest" } },
720                "required": ["kind"],
721                "additionalProperties": false
722            },
723            {
724                "type": "object",
725                "properties": { "kind": { "const": "host_confirmed" } },
726                "required": ["kind"],
727                "additionalProperties": false
728            },
729            {
730                "type": "object",
731                "properties": { "kind": { "const": "principal_confirmed" } },
732                "required": ["kind"],
733                "additionalProperties": false
734            },
735            {
736                "type": "object",
737                "properties": {
738                    "kind": { "const": "supervisor" },
739                    "owner_key": owner_key_schema()
740                },
741                "required": ["kind", "owner_key"],
742                "additionalProperties": false
743            },
744            {
745                "type": "object",
746                "properties": {
747                    "kind": { "const": "reviewer_quorum" },
748                    "threshold": { "type": "integer", "minimum": 1, "maximum": 64 }
749                },
750                "required": ["kind", "threshold"],
751                "additionalProperties": false
752            }
753        ]
754    })
755}
756
757fn policy_escalate_schema() -> Value {
758    let mut properties = base_properties();
759    properties.extend([
760        ("id".to_string(), json!({ "type": "string" })),
761        (
762            "expected_revision".to_string(),
763            json!({ "type": "integer", "minimum": 0 }),
764        ),
765        ("completion_policy".to_string(), completion_policy_schema()),
766    ]);
767    object(
768        properties,
769        &["id", "expected_revision", "completion_policy"],
770    )
771}
772
773fn close_schema() -> Value {
774    let mut properties = base_properties();
775    properties.extend([
776        ("id".to_string(), json!({ "type": "string" })),
777        (
778            "expected_revision".to_string(),
779            json!({ "type": "integer", "minimum": 0 }),
780        ),
781        (
782            "status".to_string(),
783            json!({ "type": "string", "enum": ["completed", "cancelled", "failed"] }),
784        ),
785    ]);
786    object(properties, &["id", "expected_revision"])
787}
788
789fn link_schema() -> Value {
790    let mut properties = base_properties();
791    properties.extend([
792        (
793            "kind".to_string(),
794            json!({
795                "type": "string",
796                "enum": ["blocks", "parent", "related", "supersedes", "derived_from"]
797            }),
798        ),
799        ("from_id".to_string(), json!({ "type": "string" })),
800        ("to_id".to_string(), json!({ "type": "string" })),
801    ]);
802    object(properties, &["kind", "from_id", "to_id"])
803}
804
805fn evidence_schema() -> Value {
806    let mut properties = base_properties();
807    properties.extend([
808        ("id".to_string(), json!({ "type": "string" })),
809        (
810            "expected_revision".to_string(),
811            json!({ "type": "integer", "minimum": 0 }),
812        ),
813        ("evidence".to_string(), evidence_ref_schema()),
814    ]);
815    object(properties, &["id", "expected_revision", "evidence"])
816}
817
818#[cfg(test)]
819#[allow(clippy::expect_used, clippy::unwrap_used)]
820mod tests {
821    use std::collections::BTreeSet;
822    use std::sync::Arc;
823
824    use serde_json::json;
825
826    use crate::{MemoryWorkGraphStore, WorkGraphService, WorkNamespace};
827
828    use super::*;
829
830    #[tokio::test]
831    async fn workgraph_tools_create_and_ready_round_trip() {
832        let service = WorkGraphService::with_scope(
833            Arc::new(MemoryWorkGraphStore::new()),
834            "realm",
835            WorkNamespace::default(),
836        );
837        let created = handle_workgraph_tools_call(
838            &service,
839            "workgraph_create",
840            &json!({ "title": "tool item", "labels": ["a"] }),
841        )
842        .await
843        .expect("create");
844        let id = created["item"]["id"].as_str().expect("id").to_string();
845        let ready =
846            handle_workgraph_tools_call(&service, "workgraph_ready", &json!({ "labels": ["a"] }))
847                .await
848                .expect("ready");
849        assert_eq!(ready["items"][0]["id"].as_str(), Some(id.as_str()));
850    }
851
852    /// Canonical WorkGraph tool operation set, in `make ci` via the crate unit
853    /// lane. This is the single hand-authored snapshot of the operation surface;
854    /// the drift gate below proves the derived `WorkGraphToolContract` catalog
855    /// (`strum::EnumIter` over the enum — no hand list exists in the production
856    /// code), the advertised tool list, and the dispatch entry point (`parse`)
857    /// all agree with it exactly, in both directions.
858    const CANONICAL_WORKGRAPH_TOOL_NAMES: &[&str] = &[
859        "workgraph_create",
860        "workgraph_get",
861        "workgraph_list",
862        "workgraph_ready",
863        "workgraph_snapshot",
864        "workgraph_events",
865        "workgraph_claim",
866        "workgraph_release",
867        "workgraph_update",
868        "workgraph_block",
869        "workgraph_close",
870        "workgraph_link",
871        "workgraph_add_evidence",
872        "workgraph_policy_escalate",
873        "workgraph_attention_reassign",
874    ];
875
876    const UNSCOPED_WORKGRAPH_TOOL_NAMES: &[&str] = &[
877        "workgraph_create",
878        "workgraph_get",
879        "workgraph_list",
880        "workgraph_ready",
881        "workgraph_snapshot",
882        "workgraph_events",
883        "workgraph_claim",
884        "workgraph_release",
885        "workgraph_update",
886        "workgraph_block",
887        "workgraph_close",
888        "workgraph_link",
889        "workgraph_add_evidence",
890    ];
891
892    #[test]
893    fn workgraph_tool_catalog_matches_canonical_operation_set_without_drift() {
894        let canonical = CANONICAL_WORKGRAPH_TOOL_NAMES
895            .iter()
896            .copied()
897            .map(ToString::to_string)
898            .collect::<BTreeSet<_>>();
899        assert_eq!(
900            canonical.len(),
901            CANONICAL_WORKGRAPH_TOOL_NAMES.len(),
902            "canonical WorkGraph operation names must be unique"
903        );
904
905        // The derived contract catalog must equal the canonical set exactly —
906        // neither a missing operation nor an undeclared extra.
907        let catalog = WorkGraphToolContract::iter()
908            .map(|contract| contract.name().to_string())
909            .collect::<BTreeSet<_>>();
910        assert_eq!(
911            catalog.len(),
912            WorkGraphToolContract::iter().count(),
913            "WorkGraphToolContract variants must not share operation names"
914        );
915        assert_eq!(
916            catalog, canonical,
917            "derived WorkGraphToolContract catalog drifted from the canonical operation set"
918        );
919
920        // The advertised tool list must expose exactly the canonical surface.
921        let advertised = workgraph_tools_list()
922            .into_iter()
923            .filter_map(|tool| tool["name"].as_str().map(ToString::to_string))
924            .collect::<BTreeSet<_>>();
925        assert_eq!(
926            advertised, canonical,
927            "advertised WorkGraph tool list drifted from the canonical operation set"
928        );
929
930        // Every canonical operation must route through the single dispatch entry
931        // point, and `parse` must reject anything not in the catalog — proving
932        // the listed surface and the dispatchable surface are the same set.
933        for name in CANONICAL_WORKGRAPH_TOOL_NAMES {
934            let contract = WorkGraphToolContract::parse(name)
935                .expect("canonical WorkGraph operation must be dispatchable");
936            assert_eq!(
937                contract.name(),
938                *name,
939                "dispatch round-trip changed the operation name for {name}"
940            );
941        }
942        let unknown = WorkGraphToolContract::parse("workgraph_not_a_real_tool")
943            .expect_err("dispatch must reject operations outside the catalog");
944        assert_eq!(unknown.code, WorkGraphToolErrorCode::NotFound);
945    }
946
947    #[tokio::test]
948    async fn unscoped_workgraph_tools_exclude_attention_only_operations() {
949        let unscoped = unscoped_workgraph_tools_list()
950            .into_iter()
951            .filter_map(|tool| tool["name"].as_str().map(ToString::to_string))
952            .collect::<BTreeSet<_>>();
953        let expected = UNSCOPED_WORKGRAPH_TOOL_NAMES
954            .iter()
955            .copied()
956            .map(ToString::to_string)
957            .collect::<BTreeSet<_>>();
958        assert_eq!(unscoped, expected);
959        assert!(!unscoped.contains("workgraph_attention_reassign"));
960        assert!(!unscoped.contains("workgraph_policy_escalate"));
961
962        let service = WorkGraphService::new(Arc::new(MemoryWorkGraphStore::new()));
963        let err = handle_unscoped_workgraph_tools_call(
964            &service,
965            "workgraph_attention_reassign",
966            &json!({
967                "binding_id": "attn_1",
968                "expected_revision": 1,
969                "target": {
970                    "kind": "owner",
971                    "owner_key": { "kind": "agent", "id": "agent:mob/demo/agent/member" }
972                }
973            }),
974        )
975        .await
976        .expect_err("attention-only tool is not dispatchable from the unscoped surface");
977        assert_eq!(err.code, WorkGraphToolErrorCode::NotFound);
978
979        let err = handle_unscoped_workgraph_tools_call(
980            &service,
981            "workgraph_policy_escalate",
982            &json!({
983                "id": "item-1",
984                "expected_revision": 1,
985                "completion_policy": { "kind": "host_confirmed" }
986            }),
987        )
988        .await
989        .expect_err("policy escalation is not dispatchable from the unscoped surface");
990        assert_eq!(err.code, WorkGraphToolErrorCode::NotFound);
991    }
992
993    #[test]
994    fn workgraph_tool_schemas_do_not_expose_bare_arrays_or_objects() {
995        fn assert_schema_is_provider_safe(path: &str, schema: &Value) {
996            match schema {
997                Value::Object(map) => {
998                    let is_array = map.get("type").and_then(Value::as_str) == Some("array");
999                    assert!(
1000                        !is_array || map.contains_key("items"),
1001                        "{path} is an array schema without items"
1002                    );
1003
1004                    let is_object = map.get("type").and_then(Value::as_str) == Some("object");
1005                    assert!(
1006                        !is_object || map.contains_key("properties"),
1007                        "{path} is an object schema without properties"
1008                    );
1009
1010                    for (key, value) in map {
1011                        assert_schema_is_provider_safe(&format!("{path}.{key}"), value);
1012                    }
1013                }
1014                Value::Array(items) => {
1015                    for (index, value) in items.iter().enumerate() {
1016                        assert_schema_is_provider_safe(&format!("{path}[{index}]"), value);
1017                    }
1018                }
1019                _ => {}
1020            }
1021        }
1022
1023        for tool in workgraph_tools_list() {
1024            let name = tool["name"].as_str().expect("tool name");
1025            assert_schema_is_provider_safe(name, &tool["inputSchema"]);
1026        }
1027    }
1028}