Skip to main content

loonfs_api/v0/
operations.rs

1//! Request/response shapes for the v0 HTTP API's operation endpoints:
2//! namespace lifecycle (create/fork/status/delete), path-oriented filesystem
3//! operations, file revisions, maintenance (checkpoint/retention), and the
4//! shared [`ApiError`] body. Explicit commits and the change feed live in
5//! [`super::commits`]; read-result shapes live in [`super::reads`].
6
7use super::ContentToken;
8use crate::{
9    AbsolutePath, AttributeKey, AttributeRevisionNo, AttributeValue, ChangeSeq, CheckpointId,
10    CommitId, ContentRef, InodeId, ManifestId, NamespaceId, RevisionNo, WriterEpoch,
11};
12use serde::{Deserialize, Serialize};
13use std::collections::BTreeMap;
14
15/// HTTP error body used by LoonFS APIs.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
18pub struct ApiError {
19    /// Stable machine-readable reason from the [`ErrorCode`](crate::ErrorCode)
20    /// registry.
21    ///
22    /// Carried as a string so clients keep working when a newer server
23    /// introduces a code they do not know; use
24    /// [`ErrorCode::parse`](crate::ErrorCode::parse) for typed access.
25    pub code: String,
26    /// For `not_supported` errors, the capability-document feature key the
27    /// client should reconcile against.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub feature: Option<String>,
30    /// Human-readable error message.
31    pub message: String,
32    /// Correlation id the server assigned to the failed request; the same
33    /// value is sent as the `x-request-id` response header.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub request_id: Option<String>,
36    /// Structured context for the code, present when the failure carries
37    /// machine-usable identity (API spec, "Standard error contract"). Boxed
38    /// so the rare detailed error does not widen every error-carrying result.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub details: Option<Box<ErrorDetails>>,
41}
42
43/// Optional machine-readable details for an [`ApiError`].
44///
45/// Clients make retry decisions from the error code and use these fields for
46/// relevant identifiers such as commit ids, writer epochs, and revisions.
47/// Fields may be absent and clients must ignore fields they do not use.
48#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
49#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
50pub struct ErrorDetails {
51    /// Idempotency key of the commit the error concerns.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub commit_id: Option<CommitId>,
54    /// Sequence at which that commit id already landed. Present when the
55    /// failure was decided against a durable commit receipt, which is what
56    /// holds the sequence; absent when nothing has committed under the id
57    /// yet and two live requests are simply claiming it at once.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub committed_seq: Option<ChangeSeq>,
60    /// Semantic identity of the mutation that already landed under that
61    /// commit id, from the same receipt as `committed_seq` and present
62    /// exactly when it is. A retry recomputes this value from the request it
63    /// just made — see
64    /// [`put_retry_fingerprint`](crate::put_retry_fingerprint) — and equality
65    /// is what proves the two are the same request.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub committed_fingerprint: Option<String>,
68    /// Position, in the request's operation list, of the operation that
69    /// failed. A commit applies all of its operations or none of them, so
70    /// this names the one that stopped the whole request.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub operation_index: Option<u32>,
73    /// Epoch the failing writer session held when it was displaced.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub fenced_epoch: Option<WriterEpoch>,
76    /// Epoch that currently owns the namespace.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub active_writer_epoch: Option<WriterEpoch>,
79    /// Writer id recorded by the current epoch's acquirer, when the head
80    /// recorded one.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub active_writer: Option<String>,
83    /// Unix milliseconds at which the current epoch's acquirer took it, when
84    /// the head recorded one. Writer ids are process labels, so two runs on
85    /// one machine can share one; the stamp is what tells them apart.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub active_acquired_at_ms: Option<u64>,
88    /// Inode the failed precondition or operation targeted.
89    #[serde(
90        default,
91        skip_serializing_if = "Option::is_none",
92        with = "crate::public_inode_id::option"
93    )]
94    #[cfg_attr(
95        feature = "openapi",
96        schema(schema_with = crate::public_inode_id::optional_schema)
97    )]
98    pub inode_id: Option<InodeId>,
99    /// Revision the request expected to be current.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub expected_revision_no: Option<RevisionNo>,
102    /// Revision that is actually current; absent when the inode has none.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub actual_revision_no: Option<RevisionNo>,
105    /// Attribute revision the request expected to be current.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub expected_attributes_revision_no: Option<AttributeRevisionNo>,
108    /// Attribute revision that is actually current for the inode.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub actual_attributes_revision_no: Option<AttributeRevisionNo>,
111    /// Change-feed cursor the request asked to resume after.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub after_seq: Option<ChangeSeq>,
114    /// Oldest sequence still promised for incremental replay.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub retention_floor_seq: Option<ChangeSeq>,
117    /// Deletion generation an undelete asked to recover.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub requested_deletion_seq: Option<ChangeSeq>,
120    /// Deletion generation actually active for the inode.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub active_deletion_seq: Option<ChangeSeq>,
123    /// Head sequence a namespace delete required the namespace to still be
124    /// at.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub expected_head_seq: Option<ChangeSeq>,
127    /// Head sequence the namespace was actually at, which is what a caller
128    /// that still means to delete it retries against.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub actual_head_seq: Option<ChangeSeq>,
131}
132
133/// Request to create a namespace.
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
136#[serde(deny_unknown_fields)]
137pub struct CreateNamespaceRequest {
138    /// Durable namespace id to create.
139    pub namespace_id: NamespaceId,
140}
141
142/// Request to fork a namespace.
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
145#[serde(deny_unknown_fields)]
146pub struct ForkNamespaceRequest {
147    /// Durable namespace id for the fork target.
148    pub new_namespace_id: NamespaceId,
149}
150
151/// Status summary for one namespace.
152///
153/// This is the point-lookup answer to "does this namespace exist, and where
154/// is its head?" — cheaper than listing all namespaces when only one matters.
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
157pub struct NamespaceStatusResponse {
158    /// Namespace being inspected.
159    pub namespace_id: NamespaceId,
160    /// Current visible namespace sequence.
161    pub head_seq: ChangeSeq,
162    /// Current manifest pointer recorded by the head.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub current_manifest_id: Option<ManifestId>,
165    /// Number of visible WAL segments after the current manifest.
166    pub wal_tail_segments: u64,
167    /// Oldest sequence still promised for incremental replay.
168    pub retention_floor_seq: ChangeSeq,
169}
170
171/// Result of deleting a namespace.
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
174pub struct DeleteNamespaceResponse {
175    /// Namespace whose history ended.
176    pub namespace_id: NamespaceId,
177    /// The head's last committed sequence; the delete linearized
178    /// immediately after it, so this is where history ended.
179    pub head_seq: ChangeSeq,
180}
181
182/// Destination-conflict behavior for path-oriented puts, moves, and copies.
183#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
184#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
185#[serde(rename_all = "snake_case")]
186pub enum DestinationBehavior {
187    /// Fail if the destination path already exists.
188    #[default]
189    NoReplace,
190    /// Replace the current file at the destination; only a file
191    /// destination can be replaced.
192    Replace,
193}
194
195/// Directory delete behavior for path-oriented deletes.
196#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
197#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
198#[serde(rename_all = "snake_case")]
199pub enum DeleteDirectoryBehavior {
200    /// Fail if the target is a non-empty directory.
201    #[default]
202    NonRecursive,
203    /// Delete a directory subtree.
204    Recursive,
205}
206
207/// One path-oriented filesystem operation.
208///
209/// Unknown fields are rejected because concurrency guards are optional. A
210/// misspelled guard must fail decoding instead of silently becoming `None`
211/// and allowing an unguarded write. Any future fieldless variant must use
212/// empty braces so serde also rejects unexpected fields for that variant.
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
215#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
216pub enum FilesystemOperation {
217    /// Create one directory.
218    #[cfg_attr(feature = "openapi", schema(title = "FsOpCreateDirectory"))]
219    CreateDirectory {
220        /// Absolute destination path, rejected when invalid or already bound.
221        path: AbsolutePath,
222        /// Also create missing ancestor directories (the same auto-create
223        /// `put_file` performs). The final component must still be new.
224        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
225        parents: bool,
226    },
227    /// Create or replace one file with an already-durable content ref.
228    #[cfg_attr(feature = "openapi", schema(title = "FsOpPutFile"))]
229    PutFile {
230        /// Absolute destination path; missing ancestors are created automatically.
231        path: AbsolutePath,
232        /// Immutable bytes that must be covered by a valid preparation proof.
233        content_ref: ContentRef,
234        /// Whether an existing file may receive a new revision instead of causing a conflict.
235        #[serde(default)]
236        behavior: DestinationBehavior,
237        /// When set (with `replace` behavior), the put applies only while
238        /// the file's current revision is still this one; a raced write
239        /// fails the request instead of silently stacking on it, and a
240        /// missing file answers `path_not_found`.
241        #[serde(default, skip_serializing_if = "Option::is_none")]
242        expected_revision_no: Option<RevisionNo>,
243    },
244    /// Delete one path.
245    #[cfg_attr(feature = "openapi", schema(title = "FsOpDeletePath"))]
246    DeletePath {
247        /// Absolute path that must resolve to a visible inode.
248        path: AbsolutePath,
249        /// Whether a non-empty directory may be tombstoned recursively.
250        #[serde(default)]
251        behavior: DeleteDirectoryBehavior,
252        /// When set, the delete applies only if the path still resolves to
253        /// this inode; a raced rebinding fails the request instead of
254        /// deleting (and reporting a recovery handle for) the wrong inode.
255        #[serde(
256            default,
257            skip_serializing_if = "Option::is_none",
258            with = "crate::public_inode_id::option"
259        )]
260        #[cfg_attr(
261            feature = "openapi",
262            schema(schema_with = crate::public_inode_id::optional_schema)
263        )]
264        expected_inode_id: Option<InodeId>,
265    },
266    /// Move one path to another path.
267    #[cfg_attr(feature = "openapi", schema(title = "FsOpMovePath"))]
268    MovePath {
269        /// Absolute source path that must resolve to a visible inode.
270        from_path: AbsolutePath,
271        /// Absolute destination whose parent must be visible and writable.
272        to_path: AbsolutePath,
273        /// Whether an existing destination file may be replaced.
274        #[serde(default)]
275        behavior: DestinationBehavior,
276    },
277    /// Copy one file path to another path.
278    #[cfg_attr(feature = "openapi", schema(title = "FsOpCopyPath"))]
279    CopyPath {
280        /// Absolute source path that must resolve to a visible file.
281        from_path: AbsolutePath,
282        /// Absolute destination whose parent must be visible and writable.
283        to_path: AbsolutePath,
284        /// Whether an existing destination file may receive a copied revision.
285        #[serde(default)]
286        behavior: DestinationBehavior,
287    },
288    /// Restore a deleted file or subtree.
289    ///
290    /// `inode_id` and `deletion_seq` identify one exact deletion. A stale
291    /// sequence returns `not_deleted` and cannot undo a later deletion.
292    #[cfg_attr(feature = "openapi", schema(title = "FsOpUndelete"))]
293    Undelete {
294        /// Deleted inode to make reachable again.
295        #[serde(with = "crate::public_inode_id")]
296        #[cfg_attr(
297            feature = "openapi",
298            schema(schema_with = crate::public_inode_id::schema)
299        )]
300        inode_id: InodeId,
301        /// Observed deletion sequence, which prevents cancelling a newer tombstone generation.
302        deletion_seq: ChangeSeq,
303        /// Optional destination for the restored inode.
304        ///
305        /// When absent, the inode is rebound to the parent and name recorded by the
306        /// deletion. Parent identity, rather than an old path string, keeps this
307        /// correct after ancestor renames. An explicit path is required when the
308        /// deletion recorded no binding.
309        #[serde(default, skip_serializing_if = "Option::is_none")]
310        path: Option<AbsolutePath>,
311    },
312    /// Restore an older revision as the current revision for a path.
313    #[cfg_attr(feature = "openapi", schema(title = "FsOpRestoreRevision"))]
314    RestoreRevision {
315        /// Absolute path that must resolve to a visible file.
316        path: AbsolutePath,
317        /// Existing historical revision whose content will be copied into a new current revision.
318        source_revision_no: RevisionNo,
319    },
320    /// Write and remove attributes on the inode one path resolves to.
321    #[cfg_attr(feature = "openapi", schema(title = "FsOpUpdateAttributes"))]
322    UpdateAttributes {
323        /// Absolute path that must resolve to a visible file or directory.
324        path: AbsolutePath,
325        /// Attributes to write. Each key replaces whatever the inode
326        /// currently holds under it; keys the inode holds and this map does
327        /// not name are left alone.
328        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
329        set: BTreeMap<AttributeKey, AttributeValue>,
330        /// Attribute keys to remove.
331        ///
332        /// A list preserves duplicate entries so validation can report them instead
333        /// of silently deduplicating the request.
334        #[serde(default, skip_serializing_if = "Vec::is_empty")]
335        remove: Vec<AttributeKey>,
336        /// When set, the update applies only if the path still resolves to
337        /// this inode; a raced rebinding fails the request instead of
338        /// writing attributes onto the wrong inode.
339        #[serde(
340            default,
341            skip_serializing_if = "Option::is_none",
342            with = "crate::public_inode_id::option"
343        )]
344        #[cfg_attr(
345            feature = "openapi",
346            schema(schema_with = crate::public_inode_id::optional_schema)
347        )]
348        expected_inode_id: Option<InodeId>,
349        /// When set, the update applies only while the inode's attribute
350        /// revision is still this one. Absent means the update is applied
351        /// over whatever revision is current; either way the write carries
352        /// its own revision guard, so a concurrent update never merges
353        /// silently.
354        #[serde(default, skip_serializing_if = "Option::is_none")]
355        expected_attributes_revision_no: Option<AttributeRevisionNo>,
356    },
357}
358
359/// A request to commit one or more filesystem operations.
360///
361/// Operations run in order and either all succeed or none are committed. A
362/// request with one operation uses the same fingerprint rules as a batch.
363///
364/// Unknown fields are rejected here for the same reason they are on
365/// [`FilesystemOperation`]: the fields a typo can hide are the ones that
366/// decide whether the commit is guarded at all.
367#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
368#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
369#[serde(deny_unknown_fields)]
370pub struct CommitRequest {
371    /// Caller-supplied idempotency key for the whole request.
372    pub commit_id: CommitId,
373    /// Actor responsible for the commit, as supplied by the application.
374    pub actor: crate::ActorRef,
375    /// Caller annotation recorded on the commit and reported by the change
376    /// feed. Part of the commit's identity: reusing `commit_id` with a
377    /// different message is a `commit_id_reuse_conflict`, exactly as it is
378    /// for an explicit commit.
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub message: Option<String>,
381    /// Proofs for any new external content refs introduced by this request.
382    /// One proof covers every operation that names its content ref.
383    #[serde(default, skip_serializing_if = "Vec::is_empty")]
384    pub content_tokens: Vec<ContentToken>,
385    /// Ordered operations to apply. Must be non-empty; they commit all
386    /// together or not at all.
387    pub operations: Vec<FilesystemOperation>,
388}
389
390impl CommitRequest {
391    /// A request carrying exactly one operation.
392    pub fn single(
393        commit_id: CommitId,
394        actor: crate::ActorRef,
395        message: Option<String>,
396        operation: FilesystemOperation,
397    ) -> Self {
398        Self {
399            commit_id,
400            actor,
401            message,
402            content_tokens: Vec::new(),
403            operations: vec![operation],
404        }
405    }
406}
407
408/// One immutable file revision.
409#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
410#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
411pub struct FileRevision {
412    /// File inode that owns this revision.
413    #[serde(with = "crate::public_inode_id")]
414    #[cfg_attr(
415        feature = "openapi",
416        schema(schema_with = crate::public_inode_id::schema)
417    )]
418    pub inode_id: InodeId,
419    /// Revision number within the file inode.
420    pub revision_no: RevisionNo,
421    /// Namespace sequence that created this revision.
422    pub committed_seq: ChangeSeq,
423    /// Wall-clock stamp of the commit that created this revision, in Unix
424    /// milliseconds. Observational: `committed_seq` is the order.
425    pub committed_at_ms: u64,
426    /// Actor responsible for this revision, as supplied by the application.
427    pub actor: crate::ActorRef,
428    /// Content stored for this revision.
429    pub content_ref: ContentRef,
430}
431
432/// Response for listing file revisions.
433#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
434#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
435pub struct ListFileRevisionsResponse {
436    /// Namespace that was read.
437    pub namespace_id: NamespaceId,
438    /// File inode whose revisions were returned.
439    #[serde(with = "crate::public_inode_id")]
440    #[cfg_attr(
441        feature = "openapi",
442        schema(schema_with = crate::public_inode_id::schema)
443    )]
444    pub inode_id: InodeId,
445    /// Namespace head sequence used for the read.
446    pub head_seq: ChangeSeq,
447    /// Retained revisions in order.
448    pub revisions: Vec<FileRevision>,
449    /// Opaque cursor for the next page, if more revisions are available.
450    #[serde(default, skip_serializing_if = "Option::is_none")]
451    pub next_cursor: Option<String>,
452}
453
454/// Request to create a durable checkpoint pin.
455#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
456#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
457#[serde(deny_unknown_fields)]
458pub struct CreateCheckpointRequest {
459    /// Label recorded on the checkpoint record. A label, not a key: several
460    /// records may carry the same name over different bases.
461    pub name: String,
462    /// Optional lifetime; the server computes the record's expiry from its
463    /// own clock. Absent means the pin holds until explicitly released.
464    #[serde(default, skip_serializing_if = "Option::is_none")]
465    pub ttl_ms: Option<u64>,
466}
467
468/// Result of creating a checkpoint.
469#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
470#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
471pub struct CreateCheckpointResponse {
472    /// Namespace that was checkpointed.
473    pub namespace_id: NamespaceId,
474    /// Checkpoint that was created.
475    #[serde(flatten)]
476    pub checkpoint: Checkpoint,
477}
478
479/// Result of releasing a checkpoint pin.
480#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
481#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
482pub struct ReleaseCheckpointResponse {
483    /// Namespace the checkpoint belonged to.
484    pub namespace_id: NamespaceId,
485    /// Checkpoint the release targeted.
486    pub checkpoint_id: CheckpointId,
487}
488
489/// Who a checkpoint record answers to, as the record durably records it.
490///
491/// The two owners have different releases, so a listing that names the
492/// owner also says which records the release endpoint will act on: a user
493/// pin is released by id, and a fork lease is released by deleting the
494/// target namespace it protects.
495#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
496#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
497#[serde(tag = "kind", rename_all = "snake_case")]
498pub enum CheckpointOwnerSummary {
499    /// An operator-created pin, released by id or by its own expiry.
500    #[cfg_attr(feature = "openapi", schema(title = "CheckpointOwnerUser"))]
501    User {
502        /// The label the creator recorded. Not a key: several records may
503        /// carry one label over different bases.
504        name: String,
505    },
506    /// A fork target keeping its source basis alive for the length of one
507    /// fork attempt.
508    #[cfg_attr(feature = "openapi", schema(title = "CheckpointOwnerFork"))]
509    Fork {
510        /// Namespace whose continued existence keeps this pin standing.
511        target_namespace_id: NamespaceId,
512    },
513}
514
515/// One checkpoint resource, reported from what its durable record carries.
516#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
517#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
518pub struct Checkpoint {
519    /// Durable checkpoint id used to address the checkpoint for release.
520    pub checkpoint_id: CheckpointId,
521    /// Who owns the checkpoint, including the label carried by a user pin.
522    pub owner: CheckpointOwnerSummary,
523    /// Time the checkpoint record was created, in Unix milliseconds.
524    pub created_at_ms: u64,
525    /// When garbage collection may release the record without being asked,
526    /// in Unix milliseconds. Absent means the pin holds until it is
527    /// released. An instant already in the past is a record whose expiry
528    /// has passed and which no collection pass has reached yet: it is still
529    /// a root, so it is still listed.
530    #[serde(default, skip_serializing_if = "Option::is_none")]
531    pub expires_at_ms: Option<u64>,
532    /// Sequence covered by the checkpoint's pinned basis.
533    pub checkpoint_seq: ChangeSeq,
534    /// Manifest pinned by the checkpoint.
535    pub manifest_id: ManifestId,
536}
537
538/// One page of active checkpoint records.
539#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
540#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
541pub struct ListCheckpointsResponse {
542    /// Namespace the records belong to.
543    pub namespace_id: NamespaceId,
544    /// Active records in ascending checkpoint-id order. Released records are
545    /// omitted even if garbage collection has not deleted them yet.
546    pub checkpoints: Vec<Checkpoint>,
547    /// Opaque cursor for the next page.
548    #[serde(default, skip_serializing_if = "Option::is_none")]
549    pub next_cursor: Option<String>,
550}
551
552/// How one WAL flush satisfied its goal.
553#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
554#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
555#[serde(rename_all = "snake_case")]
556pub enum FlushWalOutcome {
557    /// The root already covered the head; nothing was published.
558    AlreadyCurrent,
559    /// This call published a new manifest and advanced the root to it.
560    Published,
561    /// This call published a manifest, but a newer root already covered
562    /// the attempted sequence.
563    Superseded,
564}
565
566/// Result of one WAL flush: how the metadata root covers the head.
567#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
568#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
569pub struct FlushWalResponse {
570    /// Namespace whose WAL tail was flushed.
571    pub namespace_id: NamespaceId,
572    /// Head sequence the flush attempted to cover.
573    pub target_head_seq: ChangeSeq,
574    /// Manifest `metadata/root.json` references after the operation.
575    pub manifest_id: ManifestId,
576    /// Sequence covered by that manifest.
577    pub manifest_head_seq: ChangeSeq,
578    /// How the root came to cover the head.
579    pub outcome: FlushWalOutcome,
580}
581
582/// Optional overrides for one garbage-collection pass. Absent fields use
583/// the server's conservative defaults.
584///
585/// Every field is optional, so a typo would take the default instead of the
586/// override the caller asked for. Unknown fields are rejected so a misspelled
587/// `max_objects` fails loudly rather than running an unbounded pass.
588#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
589#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
590#[serde(deny_unknown_fields)]
591pub struct GcRequest {
592    /// Objects younger than this are never deleted, reachable or not. The
593    /// window has a derived safety floor (publication budgets plus provider
594    /// deadlines); a smaller value is rejected as `invalid_request`.
595    #[serde(default, skip_serializing_if = "Option::is_none")]
596    pub grace_window_ms: Option<u64>,
597    /// Maximum objects this invocation may read or decide. Omit to retain
598    /// the run-to-completion behavior.
599    ///
600    /// A completed upload session past its reclamation grace makes the pass
601    /// read every live manifest and retained WAL segment to find out
602    /// whether anything still references its content, and that read is
603    /// charged here like any other. A budget too small to finish it does
604    /// not stall the pass: the session is retained, the response sets
605    /// `content_reclamation_deferred`, and the sweep carries on through
606    /// everything else. What a chronically small budget costs is content
607    /// left unreclaimed, not progress. Give a pass at least as many objects
608    /// as the namespace has live manifests and retained segments for that
609    /// content to come back.
610    #[serde(default, skip_serializing_if = "Option::is_none")]
611    pub max_objects: Option<u64>,
612    /// Opaque resume token returned as `next_cursor` by an earlier pass
613    /// against the same namespace.
614    #[serde(default, skip_serializing_if = "Option::is_none")]
615    pub cursor: Option<String>,
616}
617
618/// Why a pass kept what it kept: `retained_candidates` split by the
619/// decision that spared each candidate.
620///
621/// The reasons are a closed set — one per place the sweep decides against
622/// deleting — so every field is always reported, and a zero is the answer
623/// that nothing was kept for that reason. The counts sum to
624/// `retained_candidates`.
625///
626/// Retention is a decision per candidate examined, not per object in the
627/// namespace: one object examined by two passes is counted by each.
628#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
629#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
630pub struct RetainedCandidates {
631    /// Selected as unreachable, then found reachable by the re-verification
632    /// that runs immediately before every deletion. A candidate the pass
633    /// already knew was reachable is never examined at all, so this counts
634    /// the namespace moving underneath the pass rather than the size of its
635    /// live set.
636    pub referenced: u64,
637    /// Unreachable, but younger than the grace window by the object's own
638    /// provider timestamp. A later pass deletes it.
639    pub grace_window: u64,
640    /// Unreachable, and the provider reported no last-modified time at all,
641    /// so the object's age is unknown and it is treated as young.
642    pub no_provider_timestamp: u64,
643    /// Unreachable, but this namespace published no manifest old enough to
644    /// say what it referenced when the grace window opened, so nothing
645    /// proves the object was already unreferenced then. A reader that pinned
646    /// its anchor inside the window may still be reading it, and the pass
647    /// keeps it until a manifest ages past the window.
648    pub no_reference_manifest: u64,
649    /// Root resolution failed somewhere in this pass, so manifest and table
650    /// deletion was suppressed wholesale (`degraded_retention` is set too).
651    pub degraded_roots: u64,
652    /// A key under a swept family that this collector does not recognize as
653    /// one of its own. Never deleted, whatever its age.
654    pub unrecognized_key: u64,
655    /// A checkpoint record this pass could have advanced but could not
656    /// prove ready: a lost compare-and-swap, an unreadable record, a fork
657    /// target not provably gone, a released record still inside its grace
658    /// window, or an active pin that is simply doing its job. The pins
659    /// themselves are listed by
660    /// `GET /v0/admin/namespaces/{ns}/checkpoints`.
661    pub checkpoint_not_releasable: u64,
662    /// An upload session waiting out a window a clock resolves: an open
663    /// session's lease plus the grace, an aborted session's grace, or a
664    /// completed session's derived content-reclamation grace.
665    /// `next_reclamation_at_ms` reports the soonest of these.
666    pub upload_session_window: u64,
667    /// An upload session held over for a reason no clock resolves: a lost
668    /// compare-and-swap, a record that vanished mid-pass, or a reference
669    /// set this pass could not establish. Only a later pass answers it.
670    pub upload_session_undecided: u64,
671    /// A completed session whose content reclamation was skipped because
672    /// the reference scan did not fit in `max_objects`
673    /// (`content_reclamation_deferred` is set too).
674    pub content_scan_deferred: u64,
675}
676
677/// Result of one mark-and-sweep garbage-collection pass.
678#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
679#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
680pub struct GcResponse {
681    /// Namespace the pass ran against.
682    pub namespace_id: NamespaceId,
683    /// Unreferenced WAL segments deleted.
684    pub deleted_wal_segments: u64,
685    /// Unreferenced metadata tables deleted.
686    pub deleted_metadata_tables: u64,
687    /// Unreferenced manifests deleted.
688    pub deleted_manifests: u64,
689    /// Released checkpoint records deleted after their grace window.
690    pub deleted_checkpoint_records: u64,
691    /// Fork-owned checkpoint records released because their target namespace
692    /// is provably gone.
693    pub released_fork_checkpoints: u64,
694    /// Checkpoint records released because their expiry passed, or because
695    /// they sit on a terminally deleted namespace.
696    #[serde(default)]
697    pub released_expired_checkpoints: u64,
698    /// Upload-session control objects deleted after the reap window.
699    #[serde(default)]
700    pub deleted_upload_sessions: u64,
701    /// Content objects reclaimed because their upload session completed,
702    /// aged past the derived reclamation grace, and nothing the namespace
703    /// can reach references them. The upload half's cleanup of abandoned
704    /// sessions is not counted here: it deletes unconditionally, whether or
705    /// not the session ever wrote anything.
706    #[serde(default)]
707    pub deleted_content_objects: u64,
708    /// Active checkpoint records released because their basis manifest is
709    /// verifiably gone.
710    #[serde(default)]
711    pub released_missing_basis_checkpoints: u64,
712    /// Candidates retained at delete time (grace window, missing
713    /// timestamps, or reachable from the fresh root set).
714    pub retained_candidates: u64,
715    /// The same total, split by the decision that spared each candidate.
716    /// The total above stays because it is what every existing consumer
717    /// reads; this says why.
718    #[serde(default)]
719    pub retained: RetainedCandidates,
720    /// True when ambiguous roots suppressed manifest/table deletion.
721    pub degraded_retention: bool,
722    /// True when the pass skipped completed-content reclamation because
723    /// what it needs — the namespace's live roots, then the reference
724    /// collection over them — did not fit in `max_objects`. Nothing was
725    /// ever decided from a partial collection; a later pass with room for
726    /// the whole scan reclaims what this one left behind. A pass that had
727    /// room for the roots swept every other candidate normally around the
728    /// skip, and one that did not also reports `budget_exhausted`.
729    #[serde(default)]
730    pub content_reclamation_deferred: bool,
731    /// True when the pass stopped because `max_objects` ran out before it
732    /// finished. Whatever it did before that is reported here and stands;
733    /// rerun with the returned cursor, or with a larger budget, to
734    /// continue. A budget too small for the namespace's own roots stops a
735    /// pass before it decides anything at all, which is what this says and
736    /// an empty report on its own does not.
737    #[serde(default)]
738    pub budget_exhausted: bool,
739    /// Opaque resume token when more candidates remain. Resuming rebuilds
740    /// every safety proof; the token carries enumeration position only and
741    /// is valid only against the same namespace.
742    #[serde(default, skip_serializing_if = "Option::is_none")]
743    pub next_cursor: Option<String>,
744    /// The soonest instant still ahead of this pass at which something it
745    /// retained becomes reclaimable: an open session's lease plus the grace
746    /// window, an aborted session's grace, or a completed session's derived
747    /// content-reclamation grace. A scheduler reads this to decide when to
748    /// come back, so a namespace needs no other side channel to have its
749    /// reclamation happen.
750    ///
751    /// It reports what this pass saw and nothing more. A pass that stopped
752    /// on `next_cursor` examined only part of the keyspace, and candidates
753    /// that age out under a plain grace window on their object timestamps
754    /// carry no deadline here at all, so absence is never a claim that
755    /// nothing is owed.
756    #[serde(default, skip_serializing_if = "Option::is_none")]
757    pub next_reclamation_at_ms: Option<u64>,
758}
759
760impl GcResponse {
761    /// An empty report for `namespace_id`, before any candidate is examined.
762    pub fn empty(namespace_id: NamespaceId) -> Self {
763        Self {
764            namespace_id,
765            deleted_wal_segments: 0,
766            deleted_metadata_tables: 0,
767            deleted_manifests: 0,
768            deleted_checkpoint_records: 0,
769            released_fork_checkpoints: 0,
770            released_expired_checkpoints: 0,
771            deleted_upload_sessions: 0,
772            deleted_content_objects: 0,
773            released_missing_basis_checkpoints: 0,
774            retained_candidates: 0,
775            retained: RetainedCandidates::default(),
776            degraded_retention: false,
777            content_reclamation_deferred: false,
778            budget_exhausted: false,
779            next_cursor: None,
780            next_reclamation_at_ms: None,
781        }
782    }
783
784    /// Records one retained candidate under the reason that spared it.
785    ///
786    /// The total and the breakdown move together here so they cannot drift:
787    /// every sweep site names a reason, and no site can count a retention
788    /// without naming one.
789    pub fn retain(&mut self, reason: RetainedReason) {
790        self.retained_candidates += 1;
791        *reason.counter(&mut self.retained) += 1;
792    }
793}
794
795/// The reason one candidate was retained, as the sweep site knows it. Each
796/// variant is the field of [`RetainedCandidates`] it counts into, where the
797/// reason itself is described.
798#[derive(Debug, Clone, Copy, PartialEq, Eq)]
799pub enum RetainedReason {
800    /// Counts into [`RetainedCandidates::referenced`].
801    Referenced,
802    /// Counts into [`RetainedCandidates::grace_window`].
803    GraceWindow,
804    /// Counts into [`RetainedCandidates::no_provider_timestamp`].
805    NoProviderTimestamp,
806    /// Counts into [`RetainedCandidates::no_reference_manifest`].
807    NoReferenceManifest,
808    /// Counts into [`RetainedCandidates::degraded_roots`].
809    DegradedRoots,
810    /// Counts into [`RetainedCandidates::unrecognized_key`].
811    UnrecognizedKey,
812    /// Counts into [`RetainedCandidates::checkpoint_not_releasable`].
813    CheckpointNotReleasable,
814    /// Counts into [`RetainedCandidates::upload_session_window`].
815    UploadSessionWindow,
816    /// Counts into [`RetainedCandidates::upload_session_undecided`].
817    UploadSessionUndecided,
818    /// Counts into [`RetainedCandidates::content_scan_deferred`].
819    ContentScanDeferred,
820}
821
822impl RetainedReason {
823    fn counter(self, retained: &mut RetainedCandidates) -> &mut u64 {
824        match self {
825            Self::Referenced => &mut retained.referenced,
826            Self::GraceWindow => &mut retained.grace_window,
827            Self::NoProviderTimestamp => &mut retained.no_provider_timestamp,
828            Self::NoReferenceManifest => &mut retained.no_reference_manifest,
829            Self::DegradedRoots => &mut retained.degraded_roots,
830            Self::UnrecognizedKey => &mut retained.unrecognized_key,
831            Self::CheckpointNotReleasable => &mut retained.checkpoint_not_releasable,
832            Self::UploadSessionWindow => &mut retained.upload_session_window,
833            Self::UploadSessionUndecided => &mut retained.upload_session_undecided,
834            Self::ContentScanDeferred => &mut retained.content_scan_deferred,
835        }
836    }
837}
838
839impl RetainedCandidates {
840    /// Every reason and its count, in a fixed order, for callers that
841    /// report the breakdown rather than read one field of it.
842    pub fn by_reason(&self) -> [(&'static str, u64); 10] {
843        [
844            ("referenced", self.referenced),
845            ("grace_window", self.grace_window),
846            ("no_provider_timestamp", self.no_provider_timestamp),
847            ("no_reference_manifest", self.no_reference_manifest),
848            ("degraded_roots", self.degraded_roots),
849            ("unrecognized_key", self.unrecognized_key),
850            ("checkpoint_not_releasable", self.checkpoint_not_releasable),
851            ("upload_session_window", self.upload_session_window),
852            ("upload_session_undecided", self.upload_session_undecided),
853            ("content_scan_deferred", self.content_scan_deferred),
854        ]
855    }
856
857    /// Folds another pass's breakdown into this one.
858    pub fn add(&mut self, other: &Self) {
859        self.referenced += other.referenced;
860        self.grace_window += other.grace_window;
861        self.no_provider_timestamp += other.no_provider_timestamp;
862        self.no_reference_manifest += other.no_reference_manifest;
863        self.degraded_roots += other.degraded_roots;
864        self.unrecognized_key += other.unrecognized_key;
865        self.checkpoint_not_releasable += other.checkpoint_not_releasable;
866        self.upload_session_window += other.upload_session_window;
867        self.upload_session_undecided += other.upload_session_undecided;
868        self.content_scan_deferred += other.content_scan_deferred;
869    }
870
871    /// The reason with the highest count, and that count. `None` when
872    /// nothing was retained. Ties go to the first in [`Self::by_reason`]
873    /// order, so one pass's report is stable.
874    pub fn top_reason(&self) -> Option<(&'static str, u64)> {
875        self.by_reason()
876            .into_iter()
877            .filter(|(_, count)| *count > 0)
878            // `max_by_key` keeps the last of equal maxima, so the reversal
879            // is what makes a tie report the earlier reason.
880            .rev()
881            .max_by_key(|(_, count)| *count)
882    }
883}
884
885/// Result of advancing the retention floor.
886#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
887#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
888pub struct AdvanceRetentionResponse {
889    /// New minimum sequence for incremental replay.
890    pub retention_floor_seq: ChangeSeq,
891}
892
893/// One explicit maintenance step: the actions it selects, and nothing more.
894///
895/// Selection is presence. Each field names one independent action, and a
896/// step runs exactly the ones the body carries — a request that selects
897/// nothing is rejected rather than quietly doing nothing. Unknown fields are
898/// rejected for the same reason: a misspelled selector would leave its action
899/// unrun, and the caller would read the empty report as "there was nothing to
900/// do".
901#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
902#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
903#[serde(deny_unknown_fields)]
904pub struct MaintenanceStepRequest {
905    /// Flush the visible WAL tail into metadata tables, then run one bounded
906    /// reorganization step.
907    #[serde(default, skip_serializing_if = "Option::is_none")]
908    pub metadata: Option<MetadataMaintenanceRequest>,
909    /// Advance the retention floor to the flushed manifest head. Nothing
910    /// surrenders replay history unless this is true.
911    #[serde(default)]
912    pub advance_retention: bool,
913    /// Run one bounded mark-and-sweep garbage-collection pass. Nothing
914    /// sweeps unless this is present.
915    #[serde(default, skip_serializing_if = "Option::is_none")]
916    pub gc: Option<GcRequest>,
917}
918
919/// Overrides for the metadata-upkeep action.
920#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
921#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
922#[serde(deny_unknown_fields)]
923pub struct MetadataMaintenanceRequest {
924    /// Flush the visible WAL tail once it reaches this many segments.
925    /// Absent uses the server's default threshold; zero, and any value above
926    /// the write-rejection threshold, are rejected as `invalid_request`.
927    #[serde(default, skip_serializing_if = "Option::is_none")]
928    pub max_wal_tail_segments: Option<u64>,
929}
930
931/// What the WAL-flush part of a maintenance step did.
932#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
933#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
934#[serde(tag = "outcome", rename_all = "snake_case")]
935pub enum WalFlushStepOutcome {
936    /// The tail was below the threshold, so there was nothing to flush.
937    NotNeeded,
938    /// The step flushed the WAL tail and advanced the metadata root.
939    Flushed {
940        /// Sequence covered by the published manifest.
941        manifest_head_seq: ChangeSeq,
942    },
943    /// The root already covered the attempted sequence — another publisher
944    /// got there first.
945    Superseded {
946        /// Sequence this step attempted to flush through.
947        attempted_seq: ChangeSeq,
948        /// Manifest the root currently references.
949        current_manifest_id: ManifestId,
950    },
951    /// A concurrent head update won the race.
952    RaceLost {
953        /// Head sequence observed before the advance attempt.
954        observed_head_seq: ChangeSeq,
955    },
956}
957
958/// What the metadata-reorganization part of a maintenance step did.
959///
960/// Deliberately coarse: the run counts and byte budgets a reorganization
961/// consumes are engine policy, not a wire contract.
962#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
963#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
964#[serde(tag = "outcome", rename_all = "snake_case")]
965pub enum ReorganizeStepOutcome {
966    /// No family group had enough delta runs to merge.
967    NotNeeded,
968    /// One family group was merged and a manifest published.
969    UnitPublished,
970    /// A group has outgrown one step, and this step started the background
971    /// streaming compaction that rebuilds it. The step published nothing;
972    /// the job publishes once, when it finishes.
973    CompactionStarted,
974    /// A job for this namespace is already running, so this step started
975    /// none. One runs at a time per namespace; a later step plans this group
976    /// again.
977    CompactionRunning,
978    /// This step's job holds the namespace's slot and is waiting for a
979    /// process compaction permit. It starts when one frees; nothing is
980    /// needed to make it.
981    CompactionAtCapacity,
982    /// A group needs a streaming compaction and this handle schedules no
983    /// background work, so nothing will run one until an operator does. The
984    /// self-hosting guide names the call.
985    CompactionRequired,
986    /// Another publisher advanced the root first; a later step retries.
987    Superseded,
988}
989
990/// Result of one explicit maintenance step.
991///
992/// One report per action the request selected, and none for an action it
993/// did not: an absent field means "not selected", never "ran and found
994/// nothing to do". The latter is what the outcomes inside a report say.
995#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
996#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
997pub struct MaintenanceStepResponse {
998    /// Namespace the step ran against.
999    pub namespace_id: NamespaceId,
1000    /// Namespace status observed before the step acted.
1001    pub status_before: NamespaceStatusResponse,
1002    /// What the metadata-upkeep action did.
1003    #[serde(default, skip_serializing_if = "Option::is_none")]
1004    pub metadata: Option<MetadataMaintenanceResponse>,
1005    /// Where the retention floor ended up.
1006    #[serde(default, skip_serializing_if = "Option::is_none")]
1007    pub retention: Option<AdvanceRetentionResponse>,
1008    /// What the collection pass reclaimed.
1009    #[serde(default, skip_serializing_if = "Option::is_none")]
1010    pub gc: Option<GcResponse>,
1011}
1012
1013/// What one metadata-upkeep action did, part by part.
1014#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1015#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1016pub struct MetadataMaintenanceResponse {
1017    /// What the WAL flush did.
1018    pub wal_flush: WalFlushStepOutcome,
1019    /// What the reorganization unit did.
1020    pub reorganize: ReorganizeStepOutcome,
1021}
1022
1023/// Options for one store contract probe. Empty today; a body is still sent
1024/// so later options do not change the shape of the request. An option this
1025/// build does not know is rejected rather than ignored, so a caller never
1026/// believes it selected something.
1027#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1028#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1029#[serde(deny_unknown_fields)]
1030pub struct StoreProbeRequest {}
1031
1032/// What one store contract probe observed, check by check.
1033#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1034#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1035pub struct StoreProbeResponse {
1036    /// Label the server minted for this run. It scopes the objects the run
1037    /// wrote, so it identifies the run in provider logs too.
1038    pub run_id: String,
1039    /// Every check the run performed, in the order it performed them. A
1040    /// failed check lives here rather than in an error: the probe answered
1041    /// the question, and the answer is that the store is wrong.
1042    pub checks: Vec<StoreProbeCheckResult>,
1043}
1044
1045/// One named contract check and what the store did with it.
1046#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1047#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1048pub struct StoreProbeCheckResult {
1049    /// Stable check name.
1050    pub name: String,
1051    /// What the store did.
1052    pub outcome: StoreProbeCheckOutcome,
1053    /// What was expected and what happened instead. Present only on
1054    /// `failed`.
1055    #[serde(default, skip_serializing_if = "Option::is_none")]
1056    pub message: Option<String>,
1057}
1058
1059/// What one contract check concluded about the store.
1060#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1061#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1062#[serde(rename_all = "snake_case")]
1063pub enum StoreProbeCheckOutcome {
1064    /// The store behaved as the contract requires.
1065    Passed,
1066    /// The store declares it cannot do this at all. Only the optional
1067    /// capabilities answer this way, and it is an answer rather than a
1068    /// fault.
1069    Unsupported,
1070    /// The store did something the contract forbids, or the operation
1071    /// failed outright.
1072    Failed,
1073}
1074
1075#[cfg(test)]
1076mod tests {
1077    use super::*;
1078    use crate::ContentId;
1079
1080    fn path(value: &str) -> AbsolutePath {
1081        AbsolutePath::parse(value).expect("valid test path")
1082    }
1083
1084    fn attribute_key(value: &str) -> AttributeKey {
1085        AttributeKey::parse(value).expect("valid test attribute key")
1086    }
1087
1088    fn sample_content_ref() -> ContentRef {
1089        ContentRef::blob_v1(
1090            ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("valid content id"),
1091            b"hello",
1092        )
1093    }
1094
1095    #[test]
1096    fn namespace_create_and_fork_responses_use_the_status_shape() {
1097        let create = NamespaceStatusResponse {
1098            namespace_id: NamespaceId::parse("demo").expect("namespace id"),
1099            head_seq: ChangeSeq(0),
1100            current_manifest_id: None,
1101            wal_tail_segments: 0,
1102            retention_floor_seq: ChangeSeq(0),
1103        };
1104        assert_eq!(
1105            serde_json::to_value(create).expect("serialize create response"),
1106            serde_json::json!({
1107                "namespace_id": "demo",
1108                "head_seq": 0,
1109                "wal_tail_segments": 0,
1110                "retention_floor_seq": 0
1111            })
1112        );
1113
1114        let fork = NamespaceStatusResponse {
1115            namespace_id: NamespaceId::parse("demo-branch").expect("namespace id"),
1116            head_seq: ChangeSeq(7),
1117            current_manifest_id: None,
1118            wal_tail_segments: 0,
1119            retention_floor_seq: ChangeSeq(7),
1120        };
1121        assert_eq!(
1122            serde_json::to_value(fork).expect("serialize fork response"),
1123            serde_json::json!({
1124                "namespace_id": "demo-branch",
1125                "head_seq": 7,
1126                "wal_tail_segments": 0,
1127                "retention_floor_seq": 7
1128            })
1129        );
1130    }
1131
1132    #[test]
1133    fn behavior_enums_use_snake_case_wire_values() {
1134        assert_eq!(
1135            DestinationBehavior::default(),
1136            DestinationBehavior::NoReplace
1137        );
1138        assert_eq!(
1139            DeleteDirectoryBehavior::default(),
1140            DeleteDirectoryBehavior::NonRecursive
1141        );
1142        assert_eq!(
1143            serde_json::to_value(DestinationBehavior::NoReplace)
1144                .expect("destination behavior json"),
1145            serde_json::json!("no_replace")
1146        );
1147        assert_eq!(
1148            serde_json::to_value(DestinationBehavior::Replace).expect("destination behavior json"),
1149            serde_json::json!("replace")
1150        );
1151        assert_eq!(
1152            serde_json::to_value(DeleteDirectoryBehavior::NonRecursive)
1153                .expect("delete behavior json"),
1154            serde_json::json!("non_recursive")
1155        );
1156        assert_eq!(
1157            serde_json::to_value(DeleteDirectoryBehavior::Recursive).expect("delete behavior json"),
1158            serde_json::json!("recursive")
1159        );
1160    }
1161
1162    #[test]
1163    fn filesystem_delete_and_move_operations_use_behavior_field() {
1164        let create_directory = FilesystemOperation::CreateDirectory {
1165            path: path("/docs"),
1166            parents: false,
1167        };
1168        assert_eq!(
1169            serde_json::to_value(&create_directory).expect("create directory op json"),
1170            serde_json::json!({
1171                "kind": "create_directory",
1172                "path": "/docs"
1173            })
1174        );
1175
1176        let create_directory_with_parents = FilesystemOperation::CreateDirectory {
1177            path: path("/docs/notes"),
1178            parents: true,
1179        };
1180        assert_eq!(
1181            serde_json::to_value(&create_directory_with_parents)
1182                .expect("create directory with parents op json"),
1183            serde_json::json!({
1184                "kind": "create_directory",
1185                "path": "/docs/notes",
1186                "parents": true
1187            })
1188        );
1189
1190        let delete = FilesystemOperation::DeletePath {
1191            path: path("/docs"),
1192            behavior: DeleteDirectoryBehavior::Recursive,
1193            expected_inode_id: None,
1194        };
1195        assert_eq!(
1196            serde_json::to_value(&delete).expect("delete op json"),
1197            serde_json::json!({
1198                "kind": "delete_path",
1199                "path": "/docs",
1200                "behavior": "recursive"
1201            })
1202        );
1203
1204        let move_path = FilesystemOperation::MovePath {
1205            from_path: path("/docs/a.txt"),
1206            to_path: path("/docs/b.txt"),
1207            behavior: DestinationBehavior::Replace,
1208        };
1209        assert_eq!(
1210            serde_json::to_value(&move_path).expect("move op json"),
1211            serde_json::json!({
1212                "kind": "move_path",
1213                "from_path": "/docs/a.txt",
1214                "to_path": "/docs/b.txt",
1215                "behavior": "replace"
1216            })
1217        );
1218
1219        let copy_path = FilesystemOperation::CopyPath {
1220            from_path: path("/docs/a.txt"),
1221            to_path: path("/docs/b.txt"),
1222            behavior: DestinationBehavior::Replace,
1223        };
1224        assert_eq!(
1225            serde_json::to_value(&copy_path).expect("copy op json"),
1226            serde_json::json!({
1227                "kind": "copy_path",
1228                "from_path": "/docs/a.txt",
1229                "to_path": "/docs/b.txt",
1230                "behavior": "replace"
1231            })
1232        );
1233
1234        let update_attributes = FilesystemOperation::UpdateAttributes {
1235            path: path("/docs/a.txt"),
1236            set: BTreeMap::from([(
1237                attribute_key("owner"),
1238                AttributeValue::parse("ada").expect("valid attribute value"),
1239            )]),
1240            remove: vec![attribute_key("draft")],
1241            expected_inode_id: Some(InodeId(7)),
1242            expected_attributes_revision_no: Some(AttributeRevisionNo(3)),
1243        };
1244        assert_eq!(
1245            serde_json::to_value(&update_attributes).expect("update attributes op json"),
1246            serde_json::json!({
1247                "kind": "update_attributes",
1248                "path": "/docs/a.txt",
1249                "set": {"owner": "ada"},
1250                "remove": ["draft"],
1251                "expected_inode_id": "ino_7",
1252                "expected_attributes_revision_no": 3
1253            })
1254        );
1255    }
1256
1257    #[test]
1258    fn update_attributes_omits_empty_collections_and_absent_guards() {
1259        let set_only = FilesystemOperation::UpdateAttributes {
1260            path: path("/docs/a.txt"),
1261            set: BTreeMap::from([(
1262                attribute_key("owner"),
1263                AttributeValue::parse("ada,grace").expect("valid attribute value"),
1264            )]),
1265            remove: Vec::new(),
1266            expected_inode_id: None,
1267            expected_attributes_revision_no: None,
1268        };
1269        assert_eq!(
1270            serde_json::to_value(&set_only).expect("set-only op json"),
1271            serde_json::json!({
1272                "kind": "update_attributes",
1273                "path": "/docs/a.txt",
1274                "set": {"owner": "ada,grace"}
1275            })
1276        );
1277
1278        let decoded: FilesystemOperation = serde_json::from_value(serde_json::json!({
1279            "kind": "update_attributes",
1280            "path": "/docs/a.txt",
1281            "remove": ["draft"]
1282        }))
1283        .expect("remove-only op defaults the set map and both guards");
1284        assert_eq!(
1285            decoded,
1286            FilesystemOperation::UpdateAttributes {
1287                path: path("/docs/a.txt"),
1288                set: BTreeMap::new(),
1289                remove: vec![attribute_key("draft")],
1290                expected_inode_id: None,
1291                expected_attributes_revision_no: None,
1292            }
1293        );
1294    }
1295
1296    #[test]
1297    fn update_attributes_validates_keys_and_values_during_deserialization() {
1298        // The key grammar and the value shape are enforced on the way in, so
1299        // a malformed update never reaches planning.
1300        for encoded in [
1301            serde_json::json!({
1302                "kind": "update_attributes",
1303                "path": "/docs/a.txt",
1304                "set": {"": "ada"}
1305            }),
1306            serde_json::json!({
1307                "kind": "update_attributes",
1308                "path": "/docs/a.txt",
1309                "set": {"owner": {"kind": "string", "value": "ada"}}
1310            }),
1311            serde_json::json!({
1312                "kind": "update_attributes",
1313                "path": "/docs/a.txt",
1314                "remove": ["a\u{0}b"]
1315            }),
1316        ] {
1317            assert!(serde_json::from_value::<FilesystemOperation>(encoded).is_err());
1318        }
1319    }
1320
1321    #[test]
1322    fn filesystem_operations_default_omitted_behavior_fields() {
1323        let put: FilesystemOperation = serde_json::from_value(serde_json::json!({
1324            "kind": "put_file",
1325            "path": "/docs/a.txt",
1326            "content_ref": {
1327                "kind": "blob_v1",
1328                "content_id": "con_0123456789abcdef0123456789abcdef",
1329                "size_bytes": 1,
1330                "checksum": {
1331                    "algorithm": "sha256",
1332                    "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1333                }
1334            }
1335        }))
1336        .expect("put op defaults behavior");
1337        assert!(matches!(
1338            put,
1339            FilesystemOperation::PutFile {
1340                behavior: DestinationBehavior::NoReplace,
1341                expected_revision_no: None,
1342                ..
1343            }
1344        ));
1345
1346        let delete: FilesystemOperation = serde_json::from_value(serde_json::json!({
1347            "kind": "delete_path",
1348            "path": "/docs"
1349        }))
1350        .expect("delete op defaults behavior");
1351        assert_eq!(
1352            delete,
1353            FilesystemOperation::DeletePath {
1354                path: path("/docs"),
1355                behavior: DeleteDirectoryBehavior::NonRecursive,
1356                expected_inode_id: None,
1357            }
1358        );
1359
1360        let move_path: FilesystemOperation = serde_json::from_value(serde_json::json!({
1361            "kind": "move_path",
1362            "from_path": "/docs/a.txt",
1363            "to_path": "/docs/b.txt"
1364        }))
1365        .expect("move op defaults behavior");
1366        assert_eq!(
1367            move_path,
1368            FilesystemOperation::MovePath {
1369                from_path: path("/docs/a.txt"),
1370                to_path: path("/docs/b.txt"),
1371                behavior: DestinationBehavior::NoReplace,
1372            }
1373        );
1374
1375        let copy_path: FilesystemOperation = serde_json::from_value(serde_json::json!({
1376            "kind": "copy_path",
1377            "from_path": "/docs/a.txt",
1378            "to_path": "/docs/b.txt"
1379        }))
1380        .expect("copy op defaults behavior");
1381        assert_eq!(
1382            copy_path,
1383            FilesystemOperation::CopyPath {
1384                from_path: path("/docs/a.txt"),
1385                to_path: path("/docs/b.txt"),
1386                behavior: DestinationBehavior::NoReplace,
1387            }
1388        );
1389    }
1390
1391    #[test]
1392    fn filesystem_operation_paths_keep_the_plain_string_wire_shape() {
1393        let content_ref = ContentRef::blob_v1(ContentId::generate(), b"hello");
1394        let cases = [
1395            (
1396                FilesystemOperation::PutFile {
1397                    path: path("/docs/a.txt"),
1398                    content_ref: content_ref.clone(),
1399                    behavior: DestinationBehavior::NoReplace,
1400                    expected_revision_no: None,
1401                },
1402                serde_json::json!({
1403                    "kind": "put_file",
1404                    "path": "/docs/a.txt",
1405                    "content_ref": content_ref,
1406                    "behavior": "no_replace"
1407                }),
1408            ),
1409            (
1410                FilesystemOperation::Undelete {
1411                    inode_id: InodeId(7),
1412                    deletion_seq: ChangeSeq(8),
1413                    path: Some(path("/docs/restored")),
1414                },
1415                serde_json::json!({
1416                    "kind": "undelete",
1417                    "inode_id": "ino_7",
1418                    "deletion_seq": 8,
1419                    "path": "/docs/restored"
1420                }),
1421            ),
1422            (
1423                FilesystemOperation::RestoreRevision {
1424                    path: path("/docs/a.txt"),
1425                    source_revision_no: RevisionNo(2),
1426                },
1427                serde_json::json!({
1428                    "kind": "restore_revision",
1429                    "path": "/docs/a.txt",
1430                    "source_revision_no": 2
1431                }),
1432            ),
1433            (
1434                FilesystemOperation::UpdateAttributes {
1435                    path: path("/docs/a.txt"),
1436                    set: BTreeMap::new(),
1437                    remove: vec![attribute_key("draft")],
1438                    expected_inode_id: None,
1439                    expected_attributes_revision_no: None,
1440                },
1441                serde_json::json!({
1442                    "kind": "update_attributes",
1443                    "path": "/docs/a.txt",
1444                    "remove": ["draft"]
1445                }),
1446            ),
1447        ];
1448
1449        for (operation, string_shaped_json) in cases {
1450            assert_eq!(
1451                serde_json::to_value(operation).expect("serialize filesystem operation"),
1452                string_shaped_json
1453            );
1454        }
1455    }
1456
1457    #[test]
1458    fn filesystem_operation_paths_validate_during_deserialization() {
1459        for encoded in [
1460            serde_json::json!({"kind": "create_directory", "path": "relative", "parents": false}),
1461            serde_json::json!({
1462                "kind": "put_file",
1463                "path": "relative",
1464                "content_ref": ContentRef::blob_v1(ContentId::generate(), b"hello")
1465            }),
1466            serde_json::json!({"kind": "delete_path", "path": "relative"}),
1467            serde_json::json!({
1468                "kind": "move_path",
1469                "from_path": "relative",
1470                "to_path": "/target"
1471            }),
1472            serde_json::json!({
1473                "kind": "copy_path",
1474                "from_path": "/source",
1475                "to_path": "relative"
1476            }),
1477            serde_json::json!({
1478                "kind": "undelete",
1479                "inode_id": "ino_7",
1480                "deletion_seq": 8,
1481                "path": "relative"
1482            }),
1483            serde_json::json!({
1484                "kind": "restore_revision",
1485                "path": "relative",
1486                "source_revision_no": 2
1487            }),
1488            serde_json::json!({
1489                "kind": "update_attributes",
1490                "path": "relative",
1491                "remove": ["draft"]
1492            }),
1493        ] {
1494            assert!(serde_json::from_value::<FilesystemOperation>(encoded).is_err());
1495        }
1496    }
1497
1498    #[test]
1499    fn inode_request_fields_accept_only_the_public_format() {
1500        let operations = [
1501            serde_json::json!({
1502                "kind": "delete_path",
1503                "path": "/docs/a.txt",
1504                "expected_inode_id": "ino_27"
1505            }),
1506            serde_json::json!({
1507                "kind": "undelete",
1508                "inode_id": "ino_27",
1509                "deletion_seq": 8
1510            }),
1511            serde_json::json!({
1512                "kind": "update_attributes",
1513                "path": "/docs/a.txt",
1514                "expected_inode_id": "ino_27"
1515            }),
1516        ];
1517
1518        for operation in operations {
1519            serde_json::from_value::<FilesystemOperation>(operation.clone())
1520                .expect("valid public inode ID");
1521
1522            let inode_key = if operation["kind"] == "undelete" {
1523                "inode_id"
1524            } else {
1525                "expected_inode_id"
1526            };
1527            for invalid in [serde_json::json!(27), serde_json::json!("27")] {
1528                let mut invalid_operation = operation.clone();
1529                invalid_operation[inode_key] = invalid;
1530                assert!(
1531                    serde_json::from_value::<FilesystemOperation>(invalid_operation).is_err(),
1532                    "{inode_key} accepted an invalid inode ID"
1533                );
1534            }
1535        }
1536    }
1537
1538    /// A guard the server never saw must fail the request, not the
1539    /// precondition. Every guard is optional, so a misspelled one used to
1540    /// decode to `None` and let the write apply unguarded.
1541    #[test]
1542    fn a_misspelled_guard_does_not_decode() {
1543        let put = |guard: &str| {
1544            let mut operation = serde_json::json!({
1545                "kind": "put_file",
1546                "path": "/docs/a.txt",
1547                "content_ref": sample_content_ref(),
1548                "behavior": "replace"
1549            });
1550            operation[guard] = serde_json::json!(3);
1551            serde_json::json!({
1552                "commit_id": "guarded-put",
1553                "actor": crate::ActorRef::loonfs_system(),
1554                "operations": [operation]
1555            })
1556        };
1557
1558        let spelled: CommitRequest = serde_json::from_value(put("expected_revision_no"))
1559            .expect("the guard spelled correctly decodes");
1560        assert!(matches!(
1561            spelled.operations.as_slice(),
1562            [FilesystemOperation::PutFile {
1563                expected_revision_no: Some(RevisionNo(3)),
1564                ..
1565            }]
1566        ));
1567
1568        for misspelling in ["expected_revsion_no", "expectedRevisionNo"] {
1569            assert!(
1570                serde_json::from_value::<CommitRequest>(put(misspelling)).is_err(),
1571                "`{misspelling}` decoded instead of failing the request"
1572            );
1573        }
1574    }
1575
1576    #[test]
1577    fn expected_revision_no_must_fit_the_public_integer_range() {
1578        let body = |expected_revision_no: u64| {
1579            serde_json::json!({
1580                "commit_id": "bounded-revision-guard",
1581                "actor": crate::ActorRef::loonfs_system(),
1582                "operations": [{
1583                    "kind": "put_file",
1584                    "path": "/docs/a.txt",
1585                    "content_ref": sample_content_ref(),
1586                    "behavior": "replace",
1587                    "expected_revision_no": expected_revision_no
1588                }]
1589            })
1590        };
1591
1592        let request: CommitRequest = serde_json::from_value(body(crate::MAX_PUBLIC_INTEGER))
1593            .expect("deserialize the maximum revision number");
1594        assert!(matches!(
1595            request.operations.as_slice(),
1596            [FilesystemOperation::PutFile {
1597                expected_revision_no: Some(RevisionNo(value)),
1598                ..
1599            }] if *value == crate::MAX_PUBLIC_INTEGER
1600        ));
1601
1602        let error = serde_json::from_value::<CommitRequest>(body(crate::MAX_PUBLIC_INTEGER + 1))
1603            .expect_err("reject a revision number above the public limit");
1604        assert!(
1605            error
1606                .to_string()
1607                .contains("must be an integer from 0 through 9007199254740991"),
1608            "unexpected range error: {error}"
1609        );
1610    }
1611
1612    /// The whole commit request tree is strict, not just its root: a typo one
1613    /// level down hides the same guards.
1614    #[test]
1615    fn a_commit_request_rejects_unknown_fields_at_every_level() {
1616        let valid = || {
1617            serde_json::json!({
1618                "commit_id": "strict-commit",
1619                "actor": crate::ActorRef::loonfs_system(),
1620                "content_tokens": [{
1621                    "content_ref": sample_content_ref(),
1622                    "token": "opaque-proof"
1623                }],
1624                "operations": [{
1625                    "kind": "update_attributes",
1626                    "path": "/docs/a.txt",
1627                    "set": {"owner": "ada"},
1628                    "expected_inode_id": "ino_7"
1629                }]
1630            })
1631        };
1632        serde_json::from_value::<CommitRequest>(valid())
1633            .expect("the same body without a typo decodes");
1634
1635        let mut at_root = valid();
1636        at_root["mesage"] = serde_json::json!("a note");
1637
1638        let mut in_operation = valid();
1639        in_operation["operations"][0]["expectedAttributesRevisionNo"] = serde_json::json!(3);
1640
1641        let mut in_content_token = valid();
1642        in_content_token["content_tokens"][0]["expires_at_ms"] = serde_json::json!(1);
1643
1644        let mut in_content_ref = valid();
1645        in_content_ref["content_tokens"][0]["content_ref"]["sizeBytes"] = serde_json::json!(5);
1646
1647        for (level, body) in [
1648            ("the request root", at_root),
1649            ("an operation variant", in_operation),
1650            ("a nested content token", in_content_token),
1651            ("a content ref below that", in_content_ref),
1652        ] {
1653            assert!(
1654                serde_json::from_value::<CommitRequest>(body).is_err(),
1655                "an unknown field in {level} decoded instead of failing the request"
1656            );
1657        }
1658    }
1659
1660    #[test]
1661    fn checkpoint_responses_use_one_checkpoint_wire_object() {
1662        let namespace_id = NamespaceId::parse("demo").expect("namespace id");
1663        let checkpoint = Checkpoint {
1664            checkpoint_id: CheckpointId::parse("chk_00000000000000000000000000000001")
1665                .expect("checkpoint id"),
1666            owner: CheckpointOwnerSummary::User {
1667                name: "release".to_owned(),
1668            },
1669            created_at_ms: 1_752_623_000_000,
1670            expires_at_ms: Some(1_752_626_600_000),
1671            checkpoint_seq: ChangeSeq(12),
1672            manifest_id: ManifestId(9),
1673        };
1674        let checkpoint_json = serde_json::json!({
1675            "checkpoint_id": "chk_00000000000000000000000000000001",
1676            "owner": {"kind": "user", "name": "release"},
1677            "created_at_ms": 1_752_623_000_000_u64,
1678            "expires_at_ms": 1_752_626_600_000_u64,
1679            "checkpoint_seq": 12,
1680            "manifest_id": 9,
1681        });
1682        let mut create_json = checkpoint_json.clone();
1683        create_json["namespace_id"] = serde_json::json!("demo");
1684        assert_eq!(
1685            serde_json::to_value(CreateCheckpointResponse {
1686                namespace_id: namespace_id.clone(),
1687                checkpoint: checkpoint.clone(),
1688            })
1689            .expect("serialize create checkpoint response"),
1690            create_json,
1691        );
1692        assert_eq!(
1693            serde_json::to_value(ListCheckpointsResponse {
1694                namespace_id: namespace_id.clone(),
1695                checkpoints: vec![checkpoint.clone()],
1696                next_cursor: None,
1697            })
1698            .expect("serialize list checkpoints response"),
1699            serde_json::json!({
1700                "namespace_id": "demo",
1701                "checkpoints": [checkpoint_json],
1702            }),
1703        );
1704        assert_eq!(
1705            serde_json::to_value(ReleaseCheckpointResponse {
1706                namespace_id,
1707                checkpoint_id: checkpoint.checkpoint_id,
1708            })
1709            .expect("serialize release checkpoint response"),
1710            serde_json::json!({
1711                "namespace_id": "demo",
1712                "checkpoint_id": "chk_00000000000000000000000000000001",
1713            }),
1714        );
1715    }
1716
1717    #[test]
1718    fn optional_response_fields_are_omitted_and_default_when_absent() {
1719        let checkpoint_json = serde_json::to_value(Checkpoint {
1720            checkpoint_id: CheckpointId::parse("chk_00000000000000000000000000000001")
1721                .expect("checkpoint id"),
1722            owner: CheckpointOwnerSummary::User {
1723                name: "release".to_owned(),
1724            },
1725            created_at_ms: 1_752_623_000_000,
1726            expires_at_ms: None,
1727            checkpoint_seq: ChangeSeq(3),
1728            manifest_id: ManifestId(3),
1729        })
1730        .expect("serialize checkpoint");
1731        assert!(checkpoint_json.get("expires_at_ms").is_none());
1732        let checkpoint: Checkpoint = serde_json::from_value(checkpoint_json)
1733            .expect("decode checkpoint without optional fields");
1734        assert_eq!(checkpoint.expires_at_ms, None);
1735
1736        let gc = GcResponse::empty(NamespaceId::parse("demo").expect("namespace id"));
1737        let gc_json = serde_json::to_value(gc).expect("serialize gc response");
1738        assert!(gc_json.get("next_reclamation_at_ms").is_none());
1739        let gc: GcResponse =
1740            serde_json::from_value(gc_json).expect("decode gc response without optional fields");
1741        assert_eq!(gc.next_reclamation_at_ms, None);
1742    }
1743
1744    #[test]
1745    fn maintenance_step_outcomes_use_the_outcome_tag() {
1746        assert_eq!(
1747            serde_json::to_value(WalFlushStepOutcome::Flushed {
1748                manifest_head_seq: ChangeSeq(9),
1749            })
1750            .expect("serialize WAL flush outcome"),
1751            serde_json::json!({"outcome": "flushed", "manifest_head_seq": 9})
1752        );
1753        assert_eq!(
1754            serde_json::to_value(ReorganizeStepOutcome::UnitPublished)
1755                .expect("serialize reorganize outcome"),
1756            serde_json::json!({"outcome": "unit_published"})
1757        );
1758    }
1759
1760    /// The maintenance bodies are optional selectors and overrides all the
1761    /// way down, so a typo would run a different step than the caller asked
1762    /// for and report the difference as "nothing to do".
1763    #[test]
1764    fn maintenance_request_bodies_reject_unknown_fields() {
1765        serde_json::from_value::<MaintenanceStepRequest>(serde_json::json!({
1766            "metadata": {"max_wal_tail_segments": 4},
1767            "advance_retention": true,
1768            "gc": {"grace_window_ms": 1_800_000, "max_objects": 32}
1769        }))
1770        .expect("the same body without a typo decodes");
1771
1772        for body in [
1773            serde_json::json!({"advance_retenton": true}),
1774            serde_json::json!({"metadata": {"maxWalTailSegments": 4}}),
1775            serde_json::json!({"gc": {"max_object": 32}}),
1776        ] {
1777            assert!(
1778                serde_json::from_value::<MaintenanceStepRequest>(body.clone()).is_err(),
1779                "an unknown field decoded instead of failing the step: {body}"
1780            );
1781        }
1782
1783        serde_json::from_value::<CreateCheckpointRequest>(
1784            serde_json::json!({"name": "nightly", "ttl_ms": 60_000}),
1785        )
1786        .expect("the same checkpoint body without a typo decodes");
1787        assert!(serde_json::from_value::<CreateCheckpointRequest>(
1788            serde_json::json!({"name": "nightly", "ttlMs": 60_000})
1789        )
1790        .is_err());
1791
1792        // The probe body carries no options yet, so an unknown one is the
1793        // only thing it can be sent.
1794        serde_json::from_value::<StoreProbeRequest>(serde_json::json!({}))
1795            .expect("an empty probe body decodes");
1796        assert!(
1797            serde_json::from_value::<StoreProbeRequest>(serde_json::json!({"deep": true})).is_err()
1798        );
1799
1800        serde_json::from_value::<CreateNamespaceRequest>(serde_json::json!({
1801            "namespace_id": "demo"
1802        }))
1803        .expect("the same create body without a typo decodes");
1804        assert!(
1805            serde_json::from_value::<CreateNamespaceRequest>(serde_json::json!({
1806                "namespace_id": "demo",
1807                "fork_of": "other"
1808            }))
1809            .is_err()
1810        );
1811        assert!(
1812            serde_json::from_value::<ForkNamespaceRequest>(serde_json::json!({
1813                "new_namespace_id": "demo",
1814                "source_namespace_id": "other"
1815            }))
1816            .is_err()
1817        );
1818    }
1819}