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