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 stores the image assets a task's or a document's content references.
45 ///
46 /// Read exactly as [`documents`](Self::documents) is: it says what the source *holds*, not
47 /// which predicate it applies, so the second capability rule does not reach it. A source
48 /// declaring `Native` is handed a record's assets beside the record's own write — through
49 /// [`TaskSource::write_task_with_assets`](crate::TaskSource::write_task_with_assets) and its
50 /// siblings — and either keeps them beside the record or serves each at a URL it chooses,
51 /// rewriting the record's references to point there. A source declaring `Unsupported` is
52 /// never handed one: a create or a copy of a record carrying an asset into it is refused,
53 /// naming the source, the record and the asset, before anything is written for that record.
54 ///
55 /// Defaulted to [`Support::Unsupported`] when a wire value omits it, so a plugin that
56 /// predates assets says nothing here and is never handed bytes it would drop.
57 // 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`, `comments` and `priority` already use, and a second enum here would say the same thing five ways for five sibling fields. Whether the record itself can be written at all is `TaskSource::writes`, the one write declaration every write of this contract already reads.
58 #[serde(default = "no_assets")]
59 pub assets: Support,
60 /// Whether the source's tasks hold a [`Priority`](crate::Priority) at all.
61 ///
62 /// Read exactly as [`documents`](Self::documents) and [`comments`](Self::comments) are:
63 /// it says what the source *holds*, not which predicate it applies, so the second
64 /// capability rule does not reach it. A source declaring `Unsupported` reports every
65 /// task's priority as `none`, and the engine never hands it one that is not: a copy
66 /// carrying another priority to it, and a `task priority set` naming it, are both refused
67 /// before the source is asked, naming the source and the field.
68 ///
69 /// Defaulted to [`Support::Unsupported`] when a wire value omits it, so a plugin that
70 /// predates priorities says nothing here and is never handed a priority it would drop.
71 // 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.
72 #[serde(default = "no_priority")]
73 pub priority: Support,
74 /// Whether the source's tasks carry [`Task::assignees`](crate::Task::assignees): set by
75 /// the source's own `config.assignees: true`, and `unsupported` otherwise.
76 ///
77 /// Read exactly as [`priority`](Self::priority) is: it says what the source *holds*, not
78 /// which predicate it applies. A source declaring `Unsupported` reports no assignees on
79 /// any task and is never handed any: a write naming an assignee is refused before the
80 /// source is asked, naming the source and the setting, and a copy into it carries none.
81 ///
82 /// Defaulted to [`Support::Unsupported`] when a wire value omits it, and left out of the
83 /// wire while unsupported, so a plugin that predates assignees — and every source that has
84 /// not opted in — reads and is read exactly as it was.
85 // 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`, `comments`, `assets` and `priority` already use, and a second enum here would say the same thing a seventh way.
86 #[serde(
87 default = "no_assignees",
88 skip_serializing_if = "Support::is_unsupported"
89 )]
90 #[schemars(!skip_serializing_if)]
91 pub assignees: Support,
92 /// Whether the source keeps only the tasks whose priority a query lists, itself.
93 ///
94 /// A predicate, and so one the second capability rule reaches: a source declaring
95 /// `Unsupported` ignores [`TaskQuery::priorities`](crate::TaskQuery::priorities) and
96 /// returns the wider set, and the engine narrows it. Its own member rather than a reading
97 /// of [`priority`](Self::priority), because holding a priority and filtering by one are
98 /// two abilities — a board holds a priority on a field it cannot be asked to filter by.
99 ///
100 /// Defaulted to [`Support::Unsupported`] when a wire value omits it, so a plugin that
101 /// predates priorities is narrowed by the engine rather than trusted to have filtered.
102 #[serde(default = "no_priority_filter")]
103 pub filter_by_priority: Support,
104 /// Whether the source keeps only the tasks a query's
105 /// [`TaskQuery::commented_since`](crate::TaskQuery::commented_since) selects, itself: a
106 /// task one of whose comments was created or last edited at or after the instant.
107 ///
108 /// A predicate, and so one the second capability rule reaches: a source declaring
109 /// `Unsupported` ignores the instant and returns the wider set, and the engine narrows it
110 /// — by reading **that source's comments, task by task**, for every task the source's
111 /// other predicates kept. That is correct and it is not cheap, which is why a source that
112 /// can ask its store the narrower question declares `Native`. A source whose tasks have no
113 /// [`comments`](Self::comments) at all holds no comment activity, so the engine keeps none
114 /// of its tasks without asking it anything.
115 ///
116 /// Defaulted to [`Support::Unsupported`] when a wire value omits it, so a plugin that
117 /// predates the predicate is narrowed by the engine rather than trusted to have filtered.
118 #[serde(default = "no_comment_activity_filter")]
119 pub filter_by_comment_activity: Support,
120 /// Whether the source keeps only the tasks every one of a query's
121 /// [`TaskQuery::metadata`](crate::TaskQuery::metadata) matches holds of, itself.
122 ///
123 /// A predicate, and so one the second capability rule reaches: a source declaring
124 /// `Unsupported` ignores the matches and returns the wider set, and the engine narrows it
125 /// over each task's own metadata, which every read already carries.
126 ///
127 /// Defaulted to [`Support::Unsupported`] when a wire value omits it, so a plugin that
128 /// predates the predicate is narrowed by the engine rather than trusted to have filtered.
129 #[serde(default = "no_metadata_filter")]
130 pub filter_by_metadata: Support,
131 /// Whether the source keeps only the tasks whose copy origin is a query's
132 /// [`TaskQuery::origin`](crate::TaskQuery::origin), itself.
133 ///
134 /// A predicate on the terms [`filter_by_metadata`](Self::filter_by_metadata) is one, and
135 /// its own member because a source may be able to ask its store one question and not the
136 /// other: a board keeps a copy's origin in a field of its own and caller metadata in the
137 /// issue body.
138 ///
139 /// Defaulted to [`Support::Unsupported`] when a wire value omits it, on the same terms.
140 #[serde(default = "no_origin_filter")]
141 pub filter_by_origin: Support,
142 /// Whether the source answers
143 /// [`TaskSource::status_contradictions`](crate::TaskSource::status_contradictions): the
144 /// items whose card contradicts their issue's closure, which `task status reconcile`
145 /// brings in line.
146 ///
147 /// Not a predicate, so the second capability rule does not reach it: a source declaring
148 /// `Unsupported` is never asked, and is reported as holding nothing to reconcile. That is
149 /// every source whose status *is* its closure — a Linear workflow state, a file's own
150 /// `status:` — because there a card cannot contradict anything.
151 ///
152 /// Defaulted to [`Support::Unsupported`] when a wire value omits it, so a plugin that
153 /// predates the method is never sent it.
154 // llmlint: ignore[names_match_behavior, invalid_states_unrepresentable] the reason recorded at `documents` above, at a new field: whether a source answers one kind of question, in the shape its sibling declarations already use, and a second enum here would say the same thing a sixth way.
155 #[serde(default = "no_reconcile")]
156 pub reconcile_status: Support,
157 /// Whether the source answers
158 /// [`TaskSource::issue_holdings`](crate::TaskSource::issue_holdings): which of its items
159 /// an issue reference names, which `task status complete` moves to `done`.
160 ///
161 /// Read exactly as [`reconcile_status`](Self::reconcile_status) is: a source declaring
162 /// `Unsupported` is never asked and sends no request, and is listed as unsupported.
163 ///
164 /// Defaulted to [`Support::Unsupported`] when a wire value omits it, on the same terms.
165 // llmlint: ignore[names_match_behavior, invalid_states_unrepresentable] the reason recorded at `reconcile_status` above.
166 #[serde(default = "no_complete_by_issue")]
167 pub complete_by_issue: Support,
168 /// Whether the source can select tasks belonging to no project.
169 pub orphan_tasks: Support,
170 /// Whether the source filters by label itself.
171 pub filter_by_label: Support,
172 /// Whether the source filters by status itself.
173 pub filter_by_status: Support,
174 /// Whether the source searches titles itself.
175 pub search_title: Support,
176 /// Whether the source searches bodies itself.
177 pub search_content: Support,
178 /// How far the source can walk task dependencies.
179 pub task_dependencies: DependencySupport,
180 /// How far the source can walk project dependencies.
181 pub project_dependencies: DependencySupport,
182 /// The largest page the source will serve. At least 1 — a source that serves no rows
183 /// cannot be paged, and every implementation rejects zero where its config is read.
184 // 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.
185 // 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".
186 pub max_page_size: u32,
187}
188
189/// What [`Capabilities::documents`] means when a wire value does not carry it.
190///
191/// A named function rather than a `Default` on [`Support`], which has no sensible default
192/// of its own: an absent *predicate* declaration is a plugin that did not answer, while an
193/// absent document declaration is a plugin written before there were any.
194fn no_documents() -> Support {
195 Support::Unsupported
196}
197
198/// What [`Capabilities::comments`] means when a wire value does not carry it: a plugin
199/// written before there were comments, on the terms [`no_documents`] gives.
200fn no_comments() -> Support {
201 Support::Unsupported
202}
203
204/// What [`Capabilities::assets`] means when a wire value does not carry it: a plugin written
205/// before there were assets, on the terms [`no_documents`] gives.
206fn no_assets() -> Support {
207 Support::Unsupported
208}
209
210/// What [`Capabilities::priority`] means when a wire value does not carry it: a plugin
211/// written before there were priorities, on the terms [`no_documents`] gives.
212fn no_priority() -> Support {
213 Support::Unsupported
214}
215
216/// What [`Capabilities::assignees`] means when a wire value does not carry it: a plugin
217/// written before there were assignees, or a source that has not opted in, on the terms
218/// [`no_documents`] gives.
219fn no_assignees() -> Support {
220 Support::Unsupported
221}
222
223/// What [`Capabilities::filter_by_priority`] means when a wire value does not carry it: a
224/// plugin that has never heard of the predicate, and so ignores it — which rule 2 already
225/// makes the one safe reading.
226fn no_priority_filter() -> Support {
227 Support::Unsupported
228}
229
230/// What [`Capabilities::filter_by_comment_activity`] means when a wire value does not carry
231/// it: a plugin that has never heard of the predicate, on the terms [`no_priority_filter`]
232/// gives.
233fn no_comment_activity_filter() -> Support {
234 Support::Unsupported
235}
236
237/// What [`Capabilities::filter_by_metadata`] means when a wire value does not carry it: a
238/// plugin that has never heard of the predicate, on the terms [`no_priority_filter`] gives.
239fn no_metadata_filter() -> Support {
240 Support::Unsupported
241}
242
243/// What [`Capabilities::filter_by_origin`] means when a wire value does not carry it: a
244/// plugin that has never heard of the predicate, on the terms [`no_priority_filter`] gives.
245fn no_origin_filter() -> Support {
246 Support::Unsupported
247}
248
249/// What [`Capabilities::reconcile_status`] means when a wire value does not carry it: a plugin
250/// written before the method, on the terms [`no_documents`] gives.
251fn no_reconcile() -> Support {
252 Support::Unsupported
253}
254
255/// What [`Capabilities::complete_by_issue`] means when a wire value does not carry it: a plugin
256/// written before the method, on the terms [`no_documents`] gives.
257fn no_complete_by_issue() -> Support {
258 Support::Unsupported
259}
260
261/// Whether a source applies one predicate itself.
262///
263/// Keeps an `Unsupported` variant because in-memory compensation for a filter or
264/// a search is sound: the engine over-fetches and narrows. Do not conflate this
265/// with [`DependencySupport`], which has no such variant.
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
267#[serde(rename_all = "kebab-case")]
268pub enum Support {
269 /// The source applies this predicate itself.
270 Native,
271 /// The source ignores this predicate; the engine narrows the wider result.
272 Unsupported,
273}
274
275impl Support {
276 /// Whether the source applies the predicate itself.
277 #[must_use]
278 pub fn is_native(self) -> bool {
279 matches!(self, Self::Native)
280 }
281
282 /// Whether the source ignores the predicate, or does not hold the thing at all.
283 #[must_use]
284 pub fn is_unsupported(&self) -> bool {
285 matches!(self, Self::Unsupported)
286 }
287}
288
289/// How far a source can walk its own dependency edges.
290///
291/// There is deliberately **no** unsupported variant: dependency traversal is a
292/// guaranteed capability of this product, not one a source may opt out of. A
293/// source that cannot report an item's forward edges cannot implement
294/// [`TaskSource`](crate::TaskSource). The weakest declaration is
295/// [`ForwardOnly`](Self::ForwardOnly), which the engine answers in reverse by a
296/// bounded scan and reports as emulated.
297#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
298#[serde(rename_all = "kebab-case")]
299pub enum DependencySupport {
300 /// The source answers both directions itself.
301 BothDirections,
302 /// The source answers forward edges; the engine emulates the reverse.
303 ForwardOnly,
304}
305
306impl DependencySupport {
307 /// Whether the source answers the reverse direction itself.
308 #[must_use]
309 pub fn answers_reverse(self) -> bool {
310 matches!(self, Self::BothDirections)
311 }
312}