Skip to main content

onetaskgraph_plugin_api/
metadata.rs

1//! The one key a narrow metadata write names, and the refusal a source that cannot make one
2//! answers with.
3//!
4//! A narrow metadata write sets one key of one record's metadata and changes nothing else
5//! about the record — see [`TaskSource::set_task_metadata`](crate::TaskSource::set_task_metadata).
6//! What a caller may name there is narrower than what a record may hold: a record read from a
7//! source carries this product's own `onetaskgraph.` keys, and a caller writing one of them
8//! through this seam would be editing the store's bookkeeping by hand.
9
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12
13use crate::SourceError;
14
15/// One caller-owned metadata key: `<namespace>.<name>`, at least two non-empty segments
16/// separated by dots, whose first segment is not [`MetadataKey::RESERVED_NAMESPACE`].
17///
18/// Validated wherever one is built, deserialized included, so a plugin handed one never has
19/// to ask whether it names a key this product owns.
20#[derive(
21    Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
22)]
23#[serde(try_from = "String", into = "String")]
24pub struct MetadataKey(String);
25
26impl MetadataKey {
27    /// The namespace this product owns, and a narrow write therefore never names.
28    ///
29    /// Every key the contract reserves lives under it — `onetaskgraph.origin`,
30    /// `onetaskgraph.repositories`, `onetaskgraph.depends_on`, `onetaskgraph.delivers`,
31    /// `onetaskgraph.delivered_by`, `onetaskgraph.item_kind`, `onetaskgraph.template`
32    /// ([`Self::TEMPLATE_KEY`]), `onetaskgraph.copies` ([`Self::COPIES_KEY`]),
33    /// `onetaskgraph.copy_locations` ([`Self::COPY_LOCATIONS_KEY`]),
34    /// `onetaskgraph.members` ([`Self::MEMBERS_KEY`]), `onetaskgraph.member_of`
35    /// ([`Self::MEMBER_OF_KEY`]) and `onetaskgraph.assets` ([`Self::ASSETS_KEY`]) — and so
36    /// does any key it reserves later, which is why the whole namespace is refused rather than
37    /// a list.
38    pub const RESERVED_NAMESPACE: &'static str = "onetaskgraph";
39
40    /// The reserved key a task or a document rendered from a template records where it came
41    /// from under: an object holding the template's reference, the chain digest it was
42    /// rendered with, and the SHA-256 of its content and of its resolved answers.
43    ///
44    /// A key of this product's own, so no [`MetadataKey`] can name it: only a rendering write
45    /// — [`TaskSource::write_task_rendered`](crate::TaskSource::write_task_rendered) and
46    /// [`TaskSource::set_task_rendering`](crate::TaskSource::set_task_rendering) and their
47    /// document siblings — ever sets it, and a copy carries it like any other entry. The
48    /// engine builds and reads the value; a plugin only puts it where its metadata lives.
49    pub const TEMPLATE_KEY: &'static str = "onetaskgraph.template";
50
51    /// The reserved key a copied item records where it landed under: a JSON object mapping a
52    /// destination source name to the qualified id of that item's counterpart there, at
53    /// most one entry per destination.
54    ///
55    /// A key of this product's own, so [`Self::new`] refuses it like every other key in the
56    /// namespace: only the engine's copy writes it, through the narrow metadata write, and
57    /// [`Self::copies`] is how it names the key there. A plugin only puts it where its
58    /// metadata lives.
59    pub const COPIES_KEY: &'static str = "onetaskgraph.copies";
60
61    /// The reserved key a copied item records, beside [`Self::COPIES_KEY`], where each
62    /// counterpart it links to is: a JSON object mapping a destination source name to
63    /// `{"id": <the qualified id the link names>, "location": <that counterpart's Location>}`.
64    ///
65    /// An entry says something only while its `id` is what [`Self::COPIES_KEY`] names for the
66    /// same destination: a location whose link moved, or that no link stands beside, is
67    /// ignored. So [`Self::COPIES_KEY`] is unchanged by it, and a reader that knows only that
68    /// key reads exactly what it did. Written like that key, through the narrow metadata write,
69    /// and [`Self::copy_locations`] is how it names the key there.
70    pub const COPY_LOCATIONS_KEY: &'static str = "onetaskgraph.copy_locations";
71
72    /// The reserved key a copied item records while a copy is creating its counterpart: a JSON
73    /// object mapping a destination source name to `{"key", "at", "attempt", "lease_until"}` —
74    /// the create's deterministic key, when it was first taken, the copy holding it and until
75    /// when. Removed for a destination in the write that records its [`Self::COPIES_KEY`]
76    /// entry, and the key dropped once its object empties, so an item with no create in flight
77    /// carries none. Written only by the engine's copy.
78    pub const CREATING_KEY: &'static str = "onetaskgraph.creating";
79
80    /// The reserved key an item a copy created carries its create's key under, as a string,
81    /// written with the create itself — so a retried copy finds the item its earlier attempt
82    /// created by the key both computed. A source that can give an item an id of the caller's
83    /// choosing may hold the key as that id instead.
84    pub const CREATE_KEY: &'static str = "onetaskgraph.create_key";
85
86    /// The reserved key a **home** project records its member projects under: a JSON list of
87    /// qualified project ids, each in a different source from the home and from the others,
88    /// so a home has at most one member per source.
89    ///
90    /// Store-owned, like every key in the namespace: only the engine's routed writes keep it —
91    /// a copy, or a `task create` routed away from its project's source — through the home's
92    /// own project write, and nothing a caller types can name it. A plugin
93    /// only puts it where its metadata lives.
94    pub const MEMBERS_KEY: &'static str = "onetaskgraph.members";
95
96    /// The reserved key a **member** project records its home under: the home's qualified id.
97    ///
98    /// Written once, when a routed write — a copy or a `task create` — creates the member, and
99    /// never by a caller.
100    pub const MEMBER_OF_KEY: &'static str = "onetaskgraph.member_of";
101
102    /// The reserved key a hosted source records what it uploaded for a record's image assets
103    /// under: an [`AssetUploads`](crate::AssetUploads) — an object keyed by asset name, each
104    /// value `{"sha256": <lowercase hex>, "url": <string>}`.
105    ///
106    /// Written by the hosted plugin itself, in the same write that lands the record, through
107    /// [`serve_asset_references`](crate::serve_asset_references); handed back to it as
108    /// [`AssetWrite::recorded_assets`](crate::AssetWrite::recorded_assets) on the next copy onto
109    /// that record, so an asset whose SHA-256 is unchanged reuses its URL and is not uploaded
110    /// again. A copy never carries a source record's entry to its destination: the entry says
111    /// where *that* source serves the bytes.
112    pub const ASSETS_KEY: &'static str = "onetaskgraph.assets";
113
114    /// The reserved key a copy records on the **destination** task, project or document what
115    /// its source item was when the copy composed it — and, under `--comments`, a token that
116    /// covered the comments it carried. It says whether the **source** changed since that copy.
117    ///
118    /// Where the source gives a change token — [`Task::version`](crate::Task::version),
119    /// [`Project::version`](crate::Project::version), [`Document::version`](crate::Document::version)
120    /// — it is that token, as the source holds it once the copy has recorded its own link
121    /// there. Where the source gives none — a folder of Markdown gives none for a project or a
122    /// document — it is `sha256:` and 64 lowercase hex digits over the item the copy composed
123    /// from the source: its content after reference and image rewriting, and its title, status,
124    /// labels, repositories, classification and metadata as the source holds them, without the
125    /// three `copied_*` keys and the copy's own links, and before a rendering's `body_digest` is
126    /// moved onto what the destination stores. A later copy compares the digest of what it
127    /// would send now with it.
128    ///
129    /// Read back by the next copy of the same item, which writes nothing to it when this still
130    /// matches the source and the destination is unchanged since, by
131    /// [`Self::COPIED_DESTINATION_VERSION_KEY`]. Reserved: refused from a caller, and never
132    /// carried to another item.
133    pub const COPIED_VERSION_KEY: &'static str = "onetaskgraph.copied_version";
134
135    /// The reserved key a `--comments` copy records on the destination item as the boolean
136    /// `true`, and a copy without `--comments` removes: whether the copy recorded at
137    /// [`Self::COPIED_VERSION_KEY`] carried the item's comments.
138    ///
139    /// A `--comments` copy never skips an item whose destination lacks it, so a copy that adds
140    /// `--comments` over an unchanged source still carries the comments.
141    pub const COPIED_WITH_COMMENTS_KEY: &'static str = "onetaskgraph.copied_with_comments";
142
143    /// The reserved key a copy records on the destination task, project or document a digest of
144    /// that item as the copy left it — `sha256:` and 64 lowercase hex digits, computed by the
145    /// engine over the item as a read returns it without the three `copied_*` keys, which is
146    /// over the content the destination reported storing rather than the content sent. It says
147    /// whether anybody changed the **destination** item since, its title, status and edges
148    /// included.
149    ///
150    /// A later copy writes nothing to the item only when the digest of the destination as it
151    /// reads it then is still this one, so an edit at the destination — a copied comment edited
152    /// or deleted included — is restored by the next copy rather than skipped. Reserved, on the
153    /// terms of [`Self::COPIED_VERSION_KEY`].
154    pub const COPIED_DESTINATION_VERSION_KEY: &'static str =
155        "onetaskgraph.copied_destination_version";
156
157    /// The reserved key a copy onto a destination not declared private marks a task it lands
158    /// with, as the integer `1`, once every edge that task depends on is on its own record —
159    /// none withheld and kept on a private source's side instead.
160    ///
161    /// A dependency read of a task carrying it never asks a private source what it records
162    /// of that task. Nothing but a copy writes it.
163    pub const EDGES_ON_RECORD_KEY: &'static str = "onetaskgraph.edges_on_record";
164
165    /// The reserved key a task, project or document holds its attestations under: a JSON
166    /// object mapping an attestation kind to the one record of that kind — who attested, when,
167    /// the digest of the authored content they cleared and the bar they cleared it against.
168    ///
169    /// Written only by the engine's `attest` verbs, through the narrow metadata write, and
170    /// [`Self::attestations`] is how they name the key there; refused from a caller like every
171    /// other key in the namespace. A copy carries it to the destination unchanged, like any
172    /// other entry: the digest is over content a copy carries, so a record stays true of its
173    /// copy.
174    // llmlint: ignore[code_lands_in_the_domain_that_owns_it] Composing and checking an attestation is the engine's, and none of that is here: this crate is where every reserved key is spelled once (`docs/metadata.md` is held to these constants), and the only way the engine can name a reserved key on the narrow metadata seam every plugin implements is a constructor of this type, whose field is private to this crate — the same seam `COPIES_KEY` and `CREATING_KEY` use.
175    pub const ATTESTATIONS_KEY: &'static str = "onetaskgraph.attestations";
176
177    /// [`Self::COPIES_KEY`], as the one reserved key a narrow metadata write may carry.
178    ///
179    /// Not a way round [`Self::new`] for a caller: nothing a person types reaches this, and
180    /// the one write that sends it is the copy recording where an item landed.
181    #[must_use]
182    // llmlint: ignore[invalid_states_unrepresentable] A key type of its own would change the
183    // signature of the three narrow metadata writes on `TaskSource`, which every plugin
184    // implements and this crate keeps still (AGENTS.md); the reserved key reaches that seam
185    // only through this one named constructor, while `new`, deserialization, the command line
186    // and both SDKs refuse it, and the stdio boundary admits it only with a value that is links.
187    pub fn copies() -> Self {
188        Self(Self::COPIES_KEY.to_owned())
189    }
190
191    /// [`Self::ATTESTATIONS_KEY`], as the key the engine's `attest` verbs write it under.
192    ///
193    /// Not a way round [`Self::new`] for a caller: nothing a person types names it, and the one
194    /// write that sends it is an attestation the engine composed and checked.
195    #[must_use]
196    // llmlint: ignore[invalid_states_unrepresentable] The same seam as `copies`: a key type of its own would change the three narrow metadata writes every plugin implements, which this crate keeps still (AGENTS.md); `new`, deserialization, the command line and both SDKs refuse the key.
197    // llmlint: ignore[code_lands_in_the_domain_that_owns_it] As `ATTESTATIONS_KEY` above: `MetadataKey`'s field is private to this crate, so the engine names a reserved key on the narrow write only through a constructor here, as `copies` and `creating` do; the attestation itself is composed in the engine.
198    pub fn attestations() -> Self {
199        Self(Self::ATTESTATIONS_KEY.to_owned())
200    }
201
202    /// [`Self::CREATING_KEY`], as the key the engine's copy writes it under.
203    #[must_use]
204    pub fn creating() -> Self {
205        Self(Self::CREATING_KEY.to_owned())
206    }
207
208    /// Whether this is [`Self::COPIES_KEY`].
209    #[must_use]
210    pub fn is_copies(&self) -> bool {
211        self.0 == Self::COPIES_KEY
212    }
213
214    /// [`Self::COPY_LOCATIONS_KEY`], as the other reserved key a narrow metadata write may
215    /// carry: the copy writes it beside [`Self::copies`], and nothing a person types reaches it.
216    #[must_use]
217    // llmlint: ignore[invalid_states_unrepresentable] The same seam as `copies` above, for the
218    // same reason: a key type of its own would change the three narrow metadata writes every
219    // plugin implements, and `new`, deserialization, the command line and both SDKs refuse it.
220    pub fn copy_locations() -> Self {
221        Self(Self::COPY_LOCATIONS_KEY.to_owned())
222    }
223
224    /// Whether this is [`Self::COPY_LOCATIONS_KEY`].
225    #[must_use]
226    pub fn is_copy_locations(&self) -> bool {
227        self.0 == Self::COPY_LOCATIONS_KEY
228    }
229
230    /// One key, once it is established it is a caller's own dotted key.
231    ///
232    /// # Errors
233    ///
234    /// Returns a message saying why, and what to write instead, when the key has no dot, has
235    /// an empty segment, or is in [`Self::RESERVED_NAMESPACE`].
236    pub fn new(key: impl Into<String>) -> Result<Self, String> {
237        let key = key.into();
238        let segments: Vec<&str> = key.split('.').collect();
239        if segments.len() < 2 {
240            return Err(format!(
241                "the metadata key {key:?} has no namespace: a key is `<namespace>.<name>`, two \
242                 or more non-empty segments separated by dots; next: name it under a namespace \
243                 of your own, such as `myapp.{key}`"
244            ));
245        }
246        if segments.iter().any(|segment| segment.is_empty()) {
247            return Err(format!(
248                "the metadata key {key:?} has an empty segment: a key is `<namespace>.<name>`, \
249                 two or more non-empty segments separated by dots; next: remove the extra dot"
250            ));
251        }
252        if segments[0] == Self::RESERVED_NAMESPACE {
253            return Err(format!(
254                "the metadata key {key:?} is in the `{}.` namespace, which this product owns \
255                 and keeps in step itself; next: name the key under a namespace of your own",
256                Self::RESERVED_NAMESPACE
257            ));
258        }
259        Ok(Self(key))
260    }
261
262    /// The key as a record's metadata map spells it.
263    #[must_use]
264    pub fn as_str(&self) -> &str {
265        &self.0
266    }
267}
268
269impl TryFrom<String> for MetadataKey {
270    type Error = String;
271
272    fn try_from(key: String) -> Result<Self, Self::Error> {
273        Self::new(key)
274    }
275}
276
277impl From<MetadataKey> for String {
278    fn from(key: MetadataKey) -> Self {
279        key.0
280    }
281}
282
283impl std::fmt::Display for MetadataKey {
284    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285        self.0.fmt(formatter)
286    }
287}
288
289/// Which kind of record a narrow metadata write names.
290///
291/// The three a record can be, and no fourth: a refusal, a not-found error or a protocol guard
292/// that names the record takes one of these rather than a noun, so it cannot name something
293/// that is not a record.
294#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
295pub enum MetadataRecord {
296    /// A task, written through [`TaskSource::set_task_metadata`](crate::TaskSource::set_task_metadata).
297    Task,
298    /// A project, written through
299    /// [`TaskSource::set_project_metadata`](crate::TaskSource::set_project_metadata).
300    Project,
301    /// A document, written through
302    /// [`TaskSource::set_document_metadata`](crate::TaskSource::set_document_metadata).
303    Document,
304}
305
306impl MetadataRecord {
307    /// The record's noun as a message spells it: `task`, `project` or `document`.
308    #[must_use]
309    pub const fn noun(self) -> &'static str {
310        match self {
311            Self::Task => "task",
312            Self::Project => "project",
313            Self::Document => "document",
314        }
315    }
316}
317
318impl std::fmt::Display for MetadataRecord {
319    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
320        formatter.write_str(self.noun())
321    }
322}
323
324/// The refusal a source answers a narrow metadata write with when it cannot make one on its
325/// own.
326///
327/// It reads `the linear plugin cannot write a document's metadata on its own`, naming the
328/// record. Spelled once beside [`unwritable_field`](crate::unwritable_field) for that
329/// function's reason: the trait's defaults and the engine's refusal of a plugin whose
330/// handshake does not declare the write say the same thing in the same words.
331#[must_use]
332pub fn unwritable_metadata(kind: &str, record: MetadataRecord) -> SourceError {
333    SourceError::Refused {
334        message: format!("the {kind} plugin cannot write a {record}'s metadata on its own"),
335    }
336}