Skip to main content

onetaskgraph_plugin_api/
capability.rs

1//! What a source declares it can do natively, so the engine can compensate for
2//! the rest instead of reducing every source to the weakest one's floor.
3
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7/// One source's declared abilities.
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
9pub struct Capabilities {
10    /// Whether the source has projects at all.
11    pub projects: Support,
12    /// Whether the source has documents at all.
13    ///
14    /// The same shape as [`projects`](Self::projects), and read the same way: it says what
15    /// the source *holds*, not which predicate it applies. It is therefore **not** one of
16    /// the predicates the second capability rule reaches — there is no wider result set to
17    /// return and nothing for the engine to narrow. A source declaring `Unsupported` is
18    /// never asked for a document at all; the engine reads this once at the handshake,
19    /// exactly as it reads [`TaskSource::writes`](crate::TaskSource::writes), and a
20    /// document read across several sources reports such a source as holding none rather
21    /// than as having failed.
22    ///
23    /// Defaulted to [`Support::Unsupported`] when a wire value omits it, so a plugin that
24    /// predates documents says nothing here and is read as the document-free source it is.
25    // llmlint: ignore[names_match_behavior] SECOND PERMITTED REASON — this restates at a new field the justification recorded across this crate (`Capabilities.max_page_size` below, `PageRequest.limit` in query.rs, `Task::url` in work.rs) and in AGENTS.md's "The plugin contract": the approved contract specifies this field as a `Support` *in the shape `projects` already uses*, and `projects` has carried exactly this meaning — "the source has projects at all" — since before this change, as AGENTS.md and every plugin's verdict table record. A second enum here would say the same thing two ways for two sibling fields and change the serialized form of a frozen handshake. Renaming or re-typing either is the contract owner's call, not this crate's.
26    // llmlint: ignore[invalid_states_unrepresentable] the unrepresentable state named — "has documents, but some document predicate needs compensation" — is not a state this contract has: a `DocumentQuery`'s predicates are the same `text`/`labels`/`project` the task and project queries carry, and `filter_by_label`, `search_title` and `search_content` already declare how the source applies each of them, over whichever entity it is asked for. Adding a per-entity predicate axis is a contract change with no caller yet, and it would have to reach `projects` in the same breath. Recorded in AGENTS.md, "The three capability rules".
27    #[serde(default = "no_documents")]
28    pub documents: Support,
29    /// Whether the source's tasks have comments at all.
30    ///
31    /// Read exactly as [`documents`](Self::documents) is: it says what the source *holds*,
32    /// not which predicate it applies, so the second capability rule does not reach it. A
33    /// source declaring `Unsupported` is never sent a comment call — the engine reads this
34    /// once at the handshake and refuses such a call before anything is read, naming the
35    /// source and its plugin. Adding, editing and removing a comment is a write, so a source
36    /// declaring `Native` is written through only when
37    /// [`TaskSource::writes`](crate::TaskSource::writes) says it can be written at all.
38    ///
39    /// Defaulted to [`Support::Unsupported`] when a wire value omits it, so a plugin that
40    /// predates comments says nothing here and is read as the comment-free source it is.
41    // llmlint: ignore[names_match_behavior, invalid_states_unrepresentable] the reason recorded at `documents` above, at a new field: the contract says whether a source holds a kind of thing in the shape `projects` and `documents` already use, and a second enum here would say the same thing three ways for three sibling fields. Whether comments can be *written* is `TaskSource::writes`, the one write declaration every write of this contract already reads, so a read-only pairing is a source declaring `Native` here and `Unsupported` there rather than a third variant.
42    #[serde(default = "no_comments")]
43    pub comments: Support,
44    /// Whether the source's tasks hold a [`Priority`](crate::Priority) at all.
45    ///
46    /// Read exactly as [`documents`](Self::documents) and [`comments`](Self::comments) are:
47    /// it says what the source *holds*, not which predicate it applies, so the second
48    /// capability rule does not reach it. A source declaring `Unsupported` reports every
49    /// task's priority as `none`, and the engine never hands it one that is not: a copy
50    /// carrying another priority to it, and a `task priority set` naming it, are both refused
51    /// before the source is asked, naming the source and the field.
52    ///
53    /// Defaulted to [`Support::Unsupported`] when a wire value omits it, so a plugin that
54    /// predates priorities says nothing here and is never handed a priority it would drop.
55    // llmlint: ignore[names_match_behavior, invalid_states_unrepresentable] the reason recorded at `documents` above, at a new field: the contract says whether a source holds a kind of thing in the shape `projects`, `documents` and `comments` already use, and a second enum here would say the same thing four ways for four sibling fields. Whether a priority can be *written* is `TaskSource::writes`, the one write declaration every write of this contract already reads.
56    #[serde(default = "no_priority")]
57    pub priority: Support,
58    /// Whether the source keeps only the tasks whose priority a query lists, itself.
59    ///
60    /// A predicate, and so one the second capability rule reaches: a source declaring
61    /// `Unsupported` ignores [`TaskQuery::priorities`](crate::TaskQuery::priorities) and
62    /// returns the wider set, and the engine narrows it. Its own member rather than a reading
63    /// of [`priority`](Self::priority), because holding a priority and filtering by one are
64    /// two abilities — a board holds a priority on a field it cannot be asked to filter by.
65    ///
66    /// Defaulted to [`Support::Unsupported`] when a wire value omits it, so a plugin that
67    /// predates priorities is narrowed by the engine rather than trusted to have filtered.
68    #[serde(default = "no_priority_filter")]
69    pub filter_by_priority: Support,
70    /// Whether the source keeps only the tasks a query's
71    /// [`TaskQuery::commented_since`](crate::TaskQuery::commented_since) selects, itself: a
72    /// task one of whose comments was created or last edited at or after the instant.
73    ///
74    /// A predicate, and so one the second capability rule reaches: a source declaring
75    /// `Unsupported` ignores the instant and returns the wider set, and the engine narrows it
76    /// — by reading **that source's comments, task by task**, for every task the source's
77    /// other predicates kept. That is correct and it is not cheap, which is why a source that
78    /// can ask its store the narrower question declares `Native`. A source whose tasks have no
79    /// [`comments`](Self::comments) at all holds no comment activity, so the engine keeps none
80    /// of its tasks without asking it anything.
81    ///
82    /// Defaulted to [`Support::Unsupported`] when a wire value omits it, so a plugin that
83    /// predates the predicate is narrowed by the engine rather than trusted to have filtered.
84    #[serde(default = "no_comment_activity_filter")]
85    pub filter_by_comment_activity: Support,
86    /// Whether the source keeps only the tasks every one of a query's
87    /// [`TaskQuery::metadata`](crate::TaskQuery::metadata) matches holds of, itself.
88    ///
89    /// A predicate, and so one the second capability rule reaches: a source declaring
90    /// `Unsupported` ignores the matches and returns the wider set, and the engine narrows it
91    /// over each task's own metadata, which every read already carries.
92    ///
93    /// Defaulted to [`Support::Unsupported`] when a wire value omits it, so a plugin that
94    /// predates the predicate is narrowed by the engine rather than trusted to have filtered.
95    #[serde(default = "no_metadata_filter")]
96    pub filter_by_metadata: Support,
97    /// Whether the source keeps only the tasks whose copy origin is a query's
98    /// [`TaskQuery::origin`](crate::TaskQuery::origin), itself.
99    ///
100    /// A predicate on the terms [`filter_by_metadata`](Self::filter_by_metadata) is one, and
101    /// its own member because a source may be able to ask its store one question and not the
102    /// other: a board keeps a copy's origin in a field of its own and caller metadata in the
103    /// issue body.
104    ///
105    /// Defaulted to [`Support::Unsupported`] when a wire value omits it, on the same terms.
106    #[serde(default = "no_origin_filter")]
107    pub filter_by_origin: Support,
108    /// Whether the source can select tasks belonging to no project.
109    pub orphan_tasks: Support,
110    /// Whether the source filters by label itself.
111    pub filter_by_label: Support,
112    /// Whether the source filters by status itself.
113    pub filter_by_status: Support,
114    /// Whether the source searches titles itself.
115    pub search_title: Support,
116    /// Whether the source searches bodies itself.
117    pub search_content: Support,
118    /// How far the source can walk task dependencies.
119    pub task_dependencies: DependencySupport,
120    /// How far the source can walk project dependencies.
121    pub project_dependencies: DependencySupport,
122    /// The largest page the source will serve. At least 1 — a source that serves no rows
123    /// cannot be paged, and every implementation rejects zero where its config is read.
124    // llmlint: ignore[invalid_states_unrepresentable] this field's wire shape is frozen by the plugin contract every source is written against; only the contract's owner may change it, and tightening it is post-build follow-up.
125    // llmlint: ignore[boundary_inputs_validated] the boundary that reads a user's configuration does reject zero — `CapabilityConfig::max_page_size` (onetaskgraph-in-memory/src/config.rs) is a `NonZeroU32` and names the setting when it refuses. What stays a plain `u32` is this frozen contract field, which only the contract's owner may narrow — AGENTS.md, "The plugin contract".
126    pub max_page_size: u32,
127}
128
129/// What [`Capabilities::documents`] means when a wire value does not carry it.
130///
131/// A named function rather than a `Default` on [`Support`], which has no sensible default
132/// of its own: an absent *predicate* declaration is a plugin that did not answer, while an
133/// absent document declaration is a plugin written before there were any.
134fn no_documents() -> Support {
135    Support::Unsupported
136}
137
138/// What [`Capabilities::comments`] means when a wire value does not carry it: a plugin
139/// written before there were comments, on the terms [`no_documents`] gives.
140fn no_comments() -> Support {
141    Support::Unsupported
142}
143
144/// What [`Capabilities::priority`] means when a wire value does not carry it: a plugin
145/// written before there were priorities, on the terms [`no_documents`] gives.
146fn no_priority() -> Support {
147    Support::Unsupported
148}
149
150/// What [`Capabilities::filter_by_priority`] means when a wire value does not carry it: a
151/// plugin that has never heard of the predicate, and so ignores it — which rule 2 already
152/// makes the one safe reading.
153fn no_priority_filter() -> Support {
154    Support::Unsupported
155}
156
157/// What [`Capabilities::filter_by_comment_activity`] means when a wire value does not carry
158/// it: a plugin that has never heard of the predicate, on the terms [`no_priority_filter`]
159/// gives.
160fn no_comment_activity_filter() -> Support {
161    Support::Unsupported
162}
163
164/// What [`Capabilities::filter_by_metadata`] means when a wire value does not carry it: a
165/// plugin that has never heard of the predicate, on the terms [`no_priority_filter`] gives.
166fn no_metadata_filter() -> Support {
167    Support::Unsupported
168}
169
170/// What [`Capabilities::filter_by_origin`] means when a wire value does not carry it: a
171/// plugin that has never heard of the predicate, on the terms [`no_priority_filter`] gives.
172fn no_origin_filter() -> Support {
173    Support::Unsupported
174}
175
176/// Whether a source applies one predicate itself.
177///
178/// Keeps an `Unsupported` variant because in-memory compensation for a filter or
179/// a search is sound: the engine over-fetches and narrows. Do not conflate this
180/// with [`DependencySupport`], which has no such variant.
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
182#[serde(rename_all = "kebab-case")]
183pub enum Support {
184    /// The source applies this predicate itself.
185    Native,
186    /// The source ignores this predicate; the engine narrows the wider result.
187    Unsupported,
188}
189
190impl Support {
191    /// Whether the source applies the predicate itself.
192    #[must_use]
193    pub fn is_native(self) -> bool {
194        matches!(self, Self::Native)
195    }
196}
197
198/// How far a source can walk its own dependency edges.
199///
200/// There is deliberately **no** unsupported variant: dependency traversal is a
201/// guaranteed capability of this product, not one a source may opt out of. A
202/// source that cannot report an item's forward edges cannot implement
203/// [`TaskSource`](crate::TaskSource). The weakest declaration is
204/// [`ForwardOnly`](Self::ForwardOnly), which the engine answers in reverse by a
205/// bounded scan and reports as emulated.
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
207#[serde(rename_all = "kebab-case")]
208pub enum DependencySupport {
209    /// The source answers both directions itself.
210    BothDirections,
211    /// The source answers forward edges; the engine emulates the reverse.
212    ForwardOnly,
213}
214
215impl DependencySupport {
216    /// Whether the source answers the reverse direction itself.
217    #[must_use]
218    pub fn answers_reverse(self) -> bool {
219        matches!(self, Self::BothDirections)
220    }
221}