Skip to main content

loonfs_api/v0/
operations.rs

1//! Operation requests and responses for the v0 HTTP API.
2
3use super::ContentToken;
4use crate::SnapshotId;
5use crate::{
6    AbsolutePath, AccessGrants, AccessRevisionNo, ActorId, AttributeKey, AttributeRevisionNo,
7    AttributeValue, BindingGeneration, ChangeSeq, CheckpointId, CommitId, ContentRef, DisplayName,
8    InodeId, ManifestNo, NamespaceId, RevisionNo, WriterEpoch, WriterId,
9};
10use crate::{NamespaceAccess, PrincipalId, PrincipalScope};
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeMap;
13
14/// HTTP error body used by LoonFS APIs.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
17#[cfg_attr(feature = "openapi", schema(as = ErrorResponse))]
18pub struct ApiError {
19    /// The stable machine-readable error code as a string.
20    pub code: String,
21    /// The capability feature key for a `not_supported` error.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    #[cfg_attr(feature = "openapi", schema(nullable = false))]
24    pub feature: Option<String>,
25    /// Human-readable error message.
26    pub message: String,
27    /// The invalid JSON Pointer, parameter name, CLI flag, or CLI argument.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    #[cfg_attr(feature = "openapi", schema(nullable = false))]
30    pub param: Option<String>,
31    /// The request correlation ID also sent in the `x-request-id` response header.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    #[cfg_attr(feature = "openapi", schema(nullable = false))]
34    pub request_id: Option<String>,
35    /// The optional machine-readable context for the error code.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    #[cfg_attr(feature = "openapi", schema(nullable = false))]
38    pub details: Option<Box<ErrorDetails>>,
39}
40
41/// Optional machine-readable identifiers and state for an [`ApiError`].
42#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
43#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
44pub struct ErrorDetails {
45    /// Idempotency key of the commit the error concerns.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    #[cfg_attr(feature = "openapi", schema(nullable = false))]
48    pub commit_id: Option<CommitId>,
49    /// The sequence where this commit ID already landed, when recorded by a durable receipt.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    #[cfg_attr(feature = "openapi", schema(nullable = false))]
52    pub committed_seq: Option<ChangeSeq>,
53    /// The fingerprint of the mutation that landed under `commit_id`, present with `committed_seq`.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    #[cfg_attr(feature = "openapi", schema(nullable = false))]
56    pub committed_fingerprint: Option<String>,
57    /// The index of the failed operation in the request.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    #[cfg_attr(feature = "openapi", schema(nullable = false))]
60    pub operation_index: Option<u32>,
61    /// Zero-based position of the failed request precondition.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    #[cfg_attr(feature = "openapi", schema(nullable = false))]
64    pub precondition_index: Option<u32>,
65    /// Epoch the failing writer session held when it was displaced.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    #[cfg_attr(feature = "openapi", schema(nullable = false))]
68    pub fenced_writer_epoch: Option<WriterEpoch>,
69    /// Epoch that currently owns the namespace.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    #[cfg_attr(feature = "openapi", schema(nullable = false))]
72    pub active_writer_epoch: Option<WriterEpoch>,
73    /// The writer ID recorded for the current epoch, when available.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    #[cfg_attr(feature = "openapi", schema(nullable = false))]
76    pub active_writer: Option<WriterId>,
77    /// The Unix-millisecond time when the current writer acquired its epoch, when available.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    #[cfg_attr(feature = "openapi", schema(nullable = false))]
80    pub active_acquired_at_ms: Option<u64>,
81    /// Maximum writer sessions admitted by the node.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    #[cfg_attr(feature = "openapi", schema(nullable = false))]
84    pub max_writer_sessions: Option<usize>,
85    /// Inode the failed precondition or operation targeted.
86    #[serde(
87        default,
88        skip_serializing_if = "Option::is_none",
89        with = "crate::public_inode_id::option"
90    )]
91    #[cfg_attr(feature = "openapi", schema(nullable = false))]
92    pub inode_id: Option<InodeId>,
93    /// The request expected the path to contain this inode.
94    #[serde(
95        default,
96        skip_serializing_if = "Option::is_none",
97        with = "crate::public_inode_id::option"
98    )]
99    #[cfg_attr(feature = "openapi", schema(nullable = false))]
100    pub expected_inode_id: Option<InodeId>,
101    /// The path actually contained this inode.
102    #[serde(
103        default,
104        skip_serializing_if = "Option::is_none",
105        with = "crate::public_inode_id::option"
106    )]
107    #[cfg_attr(feature = "openapi", schema(nullable = false))]
108    pub actual_inode_id: Option<InodeId>,
109    /// Opaque binding token supplied by the request.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    #[cfg_attr(feature = "openapi", schema(nullable = false))]
112    pub expected_binding_generation: Option<BindingGeneration>,
113    /// Current binding token; absent for the root, which has no binding.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    #[cfg_attr(feature = "openapi", schema(nullable = false))]
116    pub actual_binding_generation: Option<BindingGeneration>,
117    /// Revision the request expected to be current.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    #[cfg_attr(feature = "openapi", schema(nullable = false))]
120    pub expected_revision_no: Option<RevisionNo>,
121    /// Revision that is actually current; absent when the inode has none.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    #[cfg_attr(feature = "openapi", schema(nullable = false))]
124    pub actual_revision_no: Option<RevisionNo>,
125    /// Attribute revision the request expected to be current.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    #[cfg_attr(feature = "openapi", schema(nullable = false))]
128    pub expected_attributes_revision_no: Option<AttributeRevisionNo>,
129    /// Attribute revision that is actually current for the inode.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    #[cfg_attr(feature = "openapi", schema(nullable = false))]
132    pub actual_attributes_revision_no: Option<AttributeRevisionNo>,
133    /// Access revision the request expected to be current.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    #[cfg_attr(feature = "openapi", schema(nullable = false))]
136    pub expected_access_revision_no: Option<AccessRevisionNo>,
137    /// Access revision that is actually current for the inode.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    #[cfg_attr(feature = "openapi", schema(nullable = false))]
140    pub actual_access_revision_no: Option<AccessRevisionNo>,
141    /// Change-feed cursor the request asked to resume after.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    #[cfg_attr(feature = "openapi", schema(nullable = false))]
144    pub after_seq: Option<ChangeSeq>,
145    /// Oldest sequence still promised for incremental replay.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    #[cfg_attr(feature = "openapi", schema(nullable = false))]
148    pub retention_floor_seq: Option<ChangeSeq>,
149    /// Deletion generation the undelete expected to be active.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    #[cfg_attr(feature = "openapi", schema(nullable = false))]
152    pub expected_deletion_seq: Option<ChangeSeq>,
153    /// Deletion generation actually active for the inode.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    #[cfg_attr(feature = "openapi", schema(nullable = false))]
156    pub actual_deletion_seq: Option<ChangeSeq>,
157    /// The head sequence required by the request.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    #[cfg_attr(feature = "openapi", schema(nullable = false))]
160    pub expected_head_seq: Option<ChangeSeq>,
161    /// The actual namespace head sequence.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    #[cfg_attr(feature = "openapi", schema(nullable = false))]
164    pub actual_head_seq: Option<ChangeSeq>,
165}
166
167/// Request to create a namespace.
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
170#[serde(deny_unknown_fields)]
171pub struct CreateNamespaceRequest {
172    /// The access mode, fixed for the namespace's life. Defaults to
173    /// unrestricted.
174    #[serde(default = "NamespaceAccess::unrestricted")]
175    pub access: NamespaceAccess,
176    /// Durable namespace id to create.
177    pub namespace_id: NamespaceId,
178}
179
180/// Request to fork a namespace.
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
183#[serde(deny_unknown_fields)]
184pub struct ForkNamespaceRequest {
185    /// Durable namespace id for the fork target.
186    pub new_namespace_id: NamespaceId,
187    /// Fork from this live snapshot instead of the current head.
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    #[cfg_attr(feature = "openapi", schema(nullable = false))]
190    pub snapshot_id: Option<SnapshotId>,
191}
192
193/// Current state for one namespace.
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
196pub struct Namespace {
197    /// The namespace's access mode.
198    pub access: NamespaceAccessMode,
199    /// Namespace ID.
200    pub namespace_id: NamespaceId,
201    /// Time the namespace was created, in Unix milliseconds.
202    pub created_at_ms: u64,
203    /// Actor that created the namespace, as supplied by the application.
204    pub created_by: ActorId,
205    /// Present only for a fork: the source it was forked from.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    #[cfg_attr(feature = "openapi", schema(nullable = false))]
208    pub fork_basis: Option<NamespaceForkBasis>,
209    /// Current visible namespace sequence.
210    pub head_seq: ChangeSeq,
211    /// Oldest sequence still promised for incremental replay.
212    pub retention_floor_seq: ChangeSeq,
213}
214
215/// A namespace's access mode as reported, without its genesis grants.
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
218#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
219pub enum NamespaceAccessMode {
220    /// Every caller holding the deployment credential may do everything.
221    Unrestricted {},
222    /// Access rows govern operations in this identity domain.
223    Acl {
224        /// Identity domain the namespace's principal ids belong to.
225        principal_scope: PrincipalScope,
226    },
227}
228
229impl From<&NamespaceAccess> for NamespaceAccessMode {
230    fn from(access: &NamespaceAccess) -> Self {
231        match access {
232            NamespaceAccess::Unrestricted {} => Self::Unrestricted {},
233            NamespaceAccess::Acl {
234                principal_scope, ..
235            } => Self::Acl {
236                principal_scope: principal_scope.clone(),
237            },
238        }
239    }
240}
241
242/// The source a forked namespace started from.
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
245pub struct NamespaceForkBasis {
246    /// Namespace the fork was taken from.
247    pub source_namespace_id: NamespaceId,
248    /// Source sequence the fork captured.
249    pub source_head_seq: ChangeSeq,
250}
251
252/// Namespace state and storage details used by maintenance.
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
254#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
255pub struct NamespaceDiagnostics {
256    /// Namespace ID.
257    pub namespace_id: NamespaceId,
258    /// Time the namespace was created, in Unix milliseconds.
259    pub created_at_ms: u64,
260    /// Actor that created the namespace, as supplied by the application.
261    pub created_by: ActorId,
262    /// Present only for a fork: the source it was forked from.
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    #[cfg_attr(feature = "openapi", schema(nullable = false))]
265    pub fork_basis: Option<NamespaceForkBasis>,
266    /// Current visible namespace sequence.
267    pub head_seq: ChangeSeq,
268    /// Oldest sequence still promised for incremental replay.
269    pub retention_floor_seq: ChangeSeq,
270    /// The namespace's current manifest number.
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    #[cfg_attr(feature = "openapi", schema(nullable = false))]
273    pub current_manifest_no: Option<ManifestNo>,
274    /// Number of visible WAL segments after the current manifest.
275    pub wal_tail_segments: u64,
276    /// Number of snapshots that had not expired when diagnostics began.
277    pub live_snapshots: u64,
278    /// Number of active user checkpoints, including expired records awaiting collection.
279    pub live_checkpoints: u64,
280}
281
282/// Result of deleting a namespace.
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
284#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
285pub struct DeleteNamespaceResponse {
286    /// Namespace whose history ended.
287    pub namespace_id: NamespaceId,
288    /// The final committed sequence before the namespace was deleted.
289    pub head_seq: ChangeSeq,
290}
291
292/// Destination-conflict behavior for path-oriented puts, moves, and copies.
293#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
294#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
295#[serde(rename_all = "snake_case")]
296pub enum DestinationBehavior {
297    /// Fail if the destination path already exists.
298    #[default]
299    NoReplace,
300    /// Replace the current destination file.
301    Replace,
302}
303
304/// Requirements for replacing a move or copy destination.
305#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
306#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
307pub struct DestinationPrecondition {
308    /// Whether an existing destination file may be replaced.
309    #[serde(default)]
310    pub behavior: DestinationBehavior,
311    /// With `replace` behavior, the destination inode required by the request.
312    #[serde(
313        rename = "expected_destination_inode_id",
314        default,
315        skip_serializing_if = "Option::is_none",
316        with = "crate::public_inode_id::option"
317    )]
318    #[cfg_attr(feature = "openapi", schema(nullable = false))]
319    pub expected_inode_id: Option<InodeId>,
320    /// With `replace` behavior and an inode precondition, the required content revision.
321    #[serde(
322        rename = "expected_destination_revision_no",
323        default,
324        skip_serializing_if = "Option::is_none"
325    )]
326    #[cfg_attr(feature = "openapi", schema(nullable = false))]
327    pub expected_revision_no: Option<RevisionNo>,
328}
329
330/// Field-name family used when validating replacement preconditions.
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332pub enum PreconditionFields {
333    /// Fields on a file put operation.
334    Put,
335    /// Destination fields on a move or copy operation.
336    Destination,
337}
338
339impl PreconditionFields {
340    fn names(self) -> (&'static str, &'static str) {
341        match self {
342            Self::Put => ("expected_revision_no", "expected_inode_id"),
343            Self::Destination => (
344                "expected_destination_revision_no",
345                "expected_destination_inode_id",
346            ),
347        }
348    }
349}
350
351/// Validated file state required before replacement.
352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
353pub struct ExpectedFileState {
354    /// Destination inode required by the request.
355    pub inode_id: InodeId,
356    /// Destination content revision required by the request.
357    pub revision_no: Option<RevisionNo>,
358}
359
360/// Why a destination precondition is not a valid request.
361#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
362#[non_exhaustive]
363pub enum DestinationPreconditionError {
364    /// A create-only operation supplied a replacement precondition.
365    #[error("destination preconditions require replace behavior")]
366    PreconditionsRequireReplace {
367        /// First supplied expectation field, with inode before revision.
368        field: &'static str,
369    },
370    /// A revision precondition did not name the inode whose revision it checks.
371    #[error(
372        "`{revision_field}` names the revision of one inode; pair it with `{inode_field}` so the precondition names which inode"
373    )]
374    RevisionRequiresInode {
375        /// Revision field supplied by the request.
376        revision_field: &'static str,
377        /// Inode field required beside the revision.
378        inode_field: &'static str,
379    },
380}
381
382impl DestinationPrecondition {
383    /// Validates the precondition and returns the required destination state.
384    pub fn resolve(
385        &self,
386        fields: PreconditionFields,
387    ) -> Result<Option<ExpectedFileState>, DestinationPreconditionError> {
388        if self.behavior == DestinationBehavior::NoReplace
389            && !matches!(
390                (self.expected_inode_id, self.expected_revision_no),
391                (None, None)
392            )
393        {
394            let (revision_field, inode_field) = fields.names();
395            return Err(DestinationPreconditionError::PreconditionsRequireReplace {
396                field: if self.expected_inode_id.is_some() {
397                    inode_field
398                } else {
399                    revision_field
400                },
401            });
402        }
403        let Some(inode_id) = self.expected_inode_id else {
404            if self.expected_revision_no.is_some() {
405                let (revision_field, inode_field) = fields.names();
406                return Err(DestinationPreconditionError::RevisionRequiresInode {
407                    revision_field,
408                    inode_field,
409                });
410            }
411            return Ok(None);
412        };
413        Ok(Some(ExpectedFileState {
414            inode_id,
415            revision_no: self.expected_revision_no,
416        }))
417    }
418}
419
420/// Rejects an attribute revision precondition without an inode precondition.
421pub fn validate_attributes_precondition(
422    expected_inode_id: Option<InodeId>,
423    expected_attributes_revision_no: Option<AttributeRevisionNo>,
424) -> Result<(), DestinationPreconditionError> {
425    validate_revision_precondition(
426        expected_inode_id,
427        expected_attributes_revision_no.is_some(),
428        "expected_attributes_revision_no",
429    )
430}
431
432/// Rejects an access revision precondition without an inode precondition.
433pub fn validate_access_precondition(
434    expected_inode_id: Option<InodeId>,
435    expected_access_revision_no: Option<AccessRevisionNo>,
436) -> Result<(), DestinationPreconditionError> {
437    validate_revision_precondition(
438        expected_inode_id,
439        expected_access_revision_no.is_some(),
440        "expected_access_revision_no",
441    )
442}
443
444fn validate_revision_precondition(
445    expected_inode_id: Option<InodeId>,
446    has_revision: bool,
447    revision_field: &'static str,
448) -> Result<(), DestinationPreconditionError> {
449    if has_revision && expected_inode_id.is_none() {
450        return Err(DestinationPreconditionError::RevisionRequiresInode {
451            revision_field,
452            inode_field: "expected_inode_id",
453        });
454    }
455    Ok(())
456}
457
458/// Directory delete behavior for path-oriented deletes.
459#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
460#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
461#[serde(rename_all = "snake_case")]
462pub enum DeleteDirectoryBehavior {
463    /// Fail if the target is a non-empty directory.
464    #[default]
465    NonRecursive,
466    /// Delete a directory subtree.
467    Recursive,
468}
469
470/// One filesystem operation.
471///
472/// Unknown fields are rejected, and fieldless variants require empty objects.
473#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
474#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
475#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
476pub enum FilesystemOperation {
477    /// Create one directory.
478    #[cfg_attr(
479        feature = "openapi",
480        schema(title = "FilesystemOperationCreateDirectory")
481    )]
482    CreateDirectory {
483        /// Absolute destination path, rejected when invalid or already bound.
484        path: AbsolutePath,
485        /// Whether to create missing ancestor directories while requiring the final
486        /// component to be new.
487        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
488        parents: bool,
489    },
490    /// Create a directory under an existing parent inode.
491    #[cfg_attr(
492        feature = "openapi",
493        schema(title = "FilesystemOperationCreateDirectoryByInode")
494    )]
495    CreateDirectoryByInode {
496        /// Parent directory.
497        #[serde(with = "crate::public_inode_id")]
498        parent_inode_id: InodeId,
499        /// New directory name.
500        display_name: DisplayName,
501    },
502    /// Create or replace one file from uploaded or inline content.
503    /// Requires exactly one of `content_ref` and `inline_content`.
504    #[cfg_attr(feature = "openapi", schema(title = "FilesystemOperationPutFile"))]
505    PutFile {
506        /// Absolute destination path; missing ancestors are created automatically.
507        path: AbsolutePath,
508        /// Uploaded content covered by a token; mutually exclusive with `inline_content`.
509        #[serde(default, skip_serializing_if = "Option::is_none")]
510        #[cfg_attr(feature = "openapi", schema(nullable = false))]
511        content_ref: Option<ContentRef>,
512        /// Complete file bytes as base64; mutually exclusive with `content_ref`.
513        #[serde(
514            default,
515            skip_serializing_if = "Option::is_none",
516            with = "crate::base64_bytes"
517        )]
518        #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, format = Byte, nullable = false))]
519        inline_content: Option<Vec<u8>>,
520        /// Whether an existing file may receive a new revision instead of causing a conflict.
521        #[serde(default)]
522        behavior: DestinationBehavior,
523        /// With `replace` behavior, the request requires the path to contain this inode.
524        #[serde(
525            default,
526            skip_serializing_if = "Option::is_none",
527            with = "crate::public_inode_id::option"
528        )]
529        #[cfg_attr(feature = "openapi", schema(nullable = false))]
530        expected_inode_id: Option<InodeId>,
531        /// With `replace` behavior and an inode precondition, the request requires this content revision.
532        #[serde(default, skip_serializing_if = "Option::is_none")]
533        #[cfg_attr(feature = "openapi", schema(nullable = false))]
534        expected_revision_no: Option<RevisionNo>,
535    },
536    /// Create a file with an unused name under an existing parent inode.
537    /// Requires exactly one of `content_ref` and `inline_content`.
538    #[cfg_attr(
539        feature = "openapi",
540        schema(title = "FilesystemOperationCreateFileByInode")
541    )]
542    CreateFileByInode {
543        /// Parent directory.
544        #[serde(with = "crate::public_inode_id")]
545        parent_inode_id: InodeId,
546        /// New file name.
547        display_name: DisplayName,
548        /// Uploaded content covered by a token; mutually exclusive with `inline_content`.
549        #[serde(default, skip_serializing_if = "Option::is_none")]
550        #[cfg_attr(feature = "openapi", schema(nullable = false))]
551        content_ref: Option<ContentRef>,
552        /// Complete file bytes as base64; mutually exclusive with `content_ref`.
553        #[serde(
554            default,
555            skip_serializing_if = "Option::is_none",
556            with = "crate::base64_bytes"
557        )]
558        #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, format = Byte, nullable = false))]
559        inline_content: Option<Vec<u8>>,
560    },
561    /// Append a revision to a file inode if its current revision matches.
562    /// Requires exactly one of `content_ref` and `inline_content`.
563    #[cfg_attr(
564        feature = "openapi",
565        schema(title = "FilesystemOperationPutFileRevisionByInode")
566    )]
567    PutFileRevisionByInode {
568        /// File to update.
569        #[serde(with = "crate::public_inode_id")]
570        inode_id: InodeId,
571        /// Uploaded content covered by a token; mutually exclusive with `inline_content`.
572        #[serde(default, skip_serializing_if = "Option::is_none")]
573        #[cfg_attr(feature = "openapi", schema(nullable = false))]
574        content_ref: Option<ContentRef>,
575        /// Complete file bytes as base64; mutually exclusive with `content_ref`.
576        #[serde(
577            default,
578            skip_serializing_if = "Option::is_none",
579            with = "crate::base64_bytes"
580        )]
581        #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, format = Byte, nullable = false))]
582        inline_content: Option<Vec<u8>>,
583        /// Current revision required for the write.
584        expected_revision_no: RevisionNo,
585    },
586    /// Delete one path.
587    #[cfg_attr(feature = "openapi", schema(title = "FilesystemOperationDeletePath"))]
588    DeletePath {
589        /// Absolute path that must resolve to a visible inode.
590        path: AbsolutePath,
591        /// Whether a non-empty directory may be tombstoned recursively.
592        #[serde(default)]
593        behavior: DeleteDirectoryBehavior,
594        /// The inode that the path must still resolve to before deletion.
595        #[serde(
596            default,
597            skip_serializing_if = "Option::is_none",
598            with = "crate::public_inode_id::option"
599        )]
600        #[cfg_attr(feature = "openapi", schema(nullable = false))]
601        expected_inode_id: Option<InodeId>,
602    },
603    /// Delete an inode if its current binding matches.
604    #[cfg_attr(
605        feature = "openapi",
606        schema(title = "FilesystemOperationDeleteByInode")
607    )]
608    DeleteByInode {
609        /// Inode to delete.
610        #[serde(with = "crate::public_inode_id")]
611        inode_id: InodeId,
612        /// Binding generation required for the delete.
613        expected_binding_generation: BindingGeneration,
614        /// Whether a non-empty directory may be tombstoned recursively.
615        #[serde(default)]
616        behavior: DeleteDirectoryBehavior,
617    },
618    /// Move one path to another path.
619    #[cfg_attr(feature = "openapi", schema(title = "FilesystemOperationMovePath"))]
620    MovePath {
621        /// Absolute source path that must resolve to a visible inode.
622        source_path: AbsolutePath,
623        /// Absolute destination whose parent must be visible and writable.
624        destination_path: AbsolutePath,
625        /// Replacement behavior and optional destination state.
626        #[serde(flatten)]
627        precondition: DestinationPrecondition,
628    },
629    /// Move an inode if its current binding matches.
630    #[cfg_attr(feature = "openapi", schema(title = "FilesystemOperationMoveByInode"))]
631    MoveByInode {
632        /// Inode to move.
633        #[serde(with = "crate::public_inode_id")]
634        inode_id: InodeId,
635        /// Binding generation required for the move.
636        expected_binding_generation: BindingGeneration,
637        /// Destination directory.
638        #[serde(with = "crate::public_inode_id")]
639        destination_parent_inode_id: InodeId,
640        /// New name.
641        destination_display_name: DisplayName,
642        /// Replacement behavior and optional destination state.
643        #[serde(flatten)]
644        precondition: DestinationPrecondition,
645    },
646    /// Copy one file path to another path.
647    #[cfg_attr(feature = "openapi", schema(title = "FilesystemOperationCopyPath"))]
648    CopyPath {
649        /// Absolute source path that must resolve to a visible file.
650        source_path: AbsolutePath,
651        /// Absolute destination whose parent must be visible and writable.
652        destination_path: AbsolutePath,
653        /// Replacement behavior and optional destination state.
654        #[serde(flatten)]
655        precondition: DestinationPrecondition,
656    },
657    /// Restore the deletion identified by `inode_id` and `deletion_seq`.
658    #[cfg_attr(feature = "openapi", schema(title = "FilesystemOperationUndelete"))]
659    Undelete {
660        /// Deleted inode to make reachable again.
661        #[serde(with = "crate::public_inode_id")]
662        inode_id: InodeId,
663        /// Observed deletion sequence, which prevents cancelling a newer tombstone generation.
664        deletion_seq: ChangeSeq,
665        /// The restore destination, or `None` to use the recorded binding.
666        #[serde(default, skip_serializing_if = "Option::is_none")]
667        #[cfg_attr(feature = "openapi", schema(nullable = false))]
668        destination_path: Option<AbsolutePath>,
669    },
670    /// Restore an older revision as the current revision for a path.
671    #[cfg_attr(
672        feature = "openapi",
673        schema(title = "FilesystemOperationRestoreRevision")
674    )]
675    RestoreRevision {
676        /// Absolute path that must resolve to a visible file.
677        path: AbsolutePath,
678        /// Existing historical revision whose content will be copied into a new current revision.
679        source_revision_no: RevisionNo,
680    },
681    /// Write and remove attributes on the inode one path resolves to.
682    #[cfg_attr(
683        feature = "openapi",
684        schema(title = "FilesystemOperationUpdateAttributes")
685    )]
686    UpdateAttributes {
687        /// Absolute path that must resolve to a visible file or directory.
688        path: AbsolutePath,
689        /// The attributes to write, replacing values for matching keys and leaving
690        /// other keys unchanged.
691        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
692        set: BTreeMap<AttributeKey, AttributeValue>,
693        /// The attribute keys to remove, including duplicates that validation must reject.
694        #[serde(default, skip_serializing_if = "Vec::is_empty")]
695        remove: Vec<AttributeKey>,
696        /// The inode that the path must still resolve to before the update.
697        #[serde(
698            default,
699            skip_serializing_if = "Option::is_none",
700            with = "crate::public_inode_id::option"
701        )]
702        #[cfg_attr(feature = "openapi", schema(nullable = false))]
703        expected_inode_id: Option<InodeId>,
704        /// With an inode precondition, the attribute revision that must still be current.
705        #[serde(default, skip_serializing_if = "Option::is_none")]
706        #[cfg_attr(feature = "openapi", schema(nullable = false))]
707        expected_attributes_revision_no: Option<AttributeRevisionNo>,
708    },
709    /// Replace the access row of the inode one path resolves to. The root
710    /// path is a valid target.
711    #[cfg_attr(feature = "openapi", schema(title = "FilesystemOperationUpdateAccess"))]
712    UpdateAccess {
713        /// Absolute path that must resolve to a visible file or directory.
714        path: AbsolutePath,
715        /// Whether the directory stops inheritance from its ancestors.
716        boundary: bool,
717        /// The inode's complete direct grants after this update.
718        grants: AccessGrants,
719        /// The inode that the path must still resolve to before the update.
720        #[serde(
721            default,
722            skip_serializing_if = "Option::is_none",
723            with = "crate::public_inode_id::option"
724        )]
725        #[cfg_attr(feature = "openapi", schema(nullable = false))]
726        expected_inode_id: Option<InodeId>,
727        /// With an inode precondition, the access revision that must still be current.
728        #[serde(default, skip_serializing_if = "Option::is_none")]
729        #[cfg_attr(feature = "openapi", schema(nullable = false))]
730        expected_access_revision_no: Option<AccessRevisionNo>,
731    },
732}
733
734impl FilesystemOperation {
735    /// Returns the content written by this operation, if any.
736    pub const fn content_ref(&self) -> Option<&ContentRef> {
737        match self {
738            Self::PutFile { content_ref, .. }
739            | Self::CreateFileByInode { content_ref, .. }
740            | Self::PutFileRevisionByInode { content_ref, .. } => content_ref.as_ref(),
741            Self::CreateDirectory { .. }
742            | Self::CreateDirectoryByInode { .. }
743            | Self::DeletePath { .. }
744            | Self::DeleteByInode { .. }
745            | Self::MovePath { .. }
746            | Self::MoveByInode { .. }
747            | Self::CopyPath { .. }
748            | Self::Undelete { .. }
749            | Self::RestoreRevision { .. }
750            | Self::UpdateAttributes { .. }
751            | Self::UpdateAccess { .. } => None,
752        }
753    }
754}
755
756/// Admission conditions checked against the candidate's pre-state before its operations.
757/// The pre-state head sequence is the last admitted commit's sequence in the batch,
758/// or the batch's base head sequence when no earlier candidate was admitted.
759#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
760#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
761#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
762pub enum CommitPrecondition {
763    /// Requires the pre-state head sequence to equal `expected_head_seq`.
764    #[cfg_attr(feature = "openapi", schema(title = "CommitPreconditionNamespaceHead"))]
765    NamespaceHead {
766        /// Sequence observed when the caller read its inputs.
767        expected_head_seq: ChangeSeq,
768    },
769    /// Requires a visible inode with the content revision the caller read.
770    #[cfg_attr(feature = "openapi", schema(title = "CommitPreconditionFileRevision"))]
771    FileRevision {
772        /// Inode whose state the caller read.
773        #[serde(with = "crate::public_inode_id")]
774        inode_id: InodeId,
775        /// Content revision observed by the caller.
776        expected_revision_no: RevisionNo,
777    },
778    /// Requires the path to retain the binding the caller read.
779    #[cfg_attr(feature = "openapi", schema(title = "CommitPreconditionPathBinding"))]
780    PathBinding {
781        /// Absolute path to check, including the root.
782        path: AbsolutePath,
783        /// Inode required at the path.
784        #[serde(with = "crate::public_inode_id")]
785        expected_inode_id: InodeId,
786        /// Detects moves away and back.
787        #[serde(default, skip_serializing_if = "Option::is_none")]
788        #[cfg_attr(feature = "openapi", schema(nullable = false))]
789        expected_binding_generation: Option<BindingGeneration>,
790    },
791    /// Requires a visible inode with the attribute revision the caller read.
792    #[cfg_attr(
793        feature = "openapi",
794        schema(title = "CommitPreconditionAttributesRevision")
795    )]
796    AttributesRevision {
797        /// Inode whose state the caller read.
798        #[serde(with = "crate::public_inode_id")]
799        inode_id: InodeId,
800        /// Attribute revision observed by the caller.
801        expected_attributes_revision_no: AttributeRevisionNo,
802    },
803    /// Requires a visible inode with the access revision the caller read.
804    #[cfg_attr(
805        feature = "openapi",
806        schema(title = "CommitPreconditionAccessRevision")
807    )]
808    AccessRevision {
809        /// Inode whose state the caller read.
810        #[serde(with = "crate::public_inode_id")]
811        inode_id: InodeId,
812        /// Access revision observed by the caller.
813        expected_access_revision_no: AccessRevisionNo,
814    },
815    /// Requires no visible entry at the full path.
816    #[cfg_attr(feature = "openapi", schema(title = "CommitPreconditionPathAbsence"))]
817    PathAbsence {
818        /// Absolute path to check, including the root.
819        path: AbsolutePath,
820    },
821}
822
823/// A request to commit one or more filesystem operations atomically in order.
824///
825/// Unknown fields are rejected.
826#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
827#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
828#[serde(deny_unknown_fields)]
829pub struct CommitRequest {
830    /// Caller-supplied idempotency key for the whole request.
831    pub commit_id: CommitId,
832    /// The caller annotation that forms part of the commit identity.
833    #[serde(default, skip_serializing_if = "Option::is_none")]
834    #[cfg_attr(feature = "openapi", schema(nullable = false))]
835    pub message: Option<String>,
836    /// The proofs for new external content references in this request.
837    #[serde(default, skip_serializing_if = "Vec::is_empty")]
838    pub content_tokens: Vec<ContentToken>,
839    /// Ordered admission conditions evaluated before any operations.
840    #[serde(default, skip_serializing_if = "Vec::is_empty")]
841    pub preconditions: Vec<CommitPrecondition>,
842    /// The non-empty ordered operations to commit atomically.
843    pub operations: Vec<FilesystemOperation>,
844}
845
846impl CommitRequest {
847    /// Sets the admission conditions in caller order.
848    pub fn preconditions(mut self, preconditions: Vec<CommitPrecondition>) -> Self {
849        self.preconditions = preconditions;
850        self
851    }
852
853    /// A request carrying exactly one operation.
854    pub fn single(
855        commit_id: CommitId,
856        message: Option<String>,
857        operation: FilesystemOperation,
858    ) -> Self {
859        Self {
860            commit_id,
861            message,
862            content_tokens: Vec::new(),
863            preconditions: Vec::new(),
864            operations: vec![operation],
865        }
866    }
867}
868
869/// One immutable file revision.
870#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
871#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
872pub struct FileRevision {
873    /// File inode that owns this revision.
874    #[serde(with = "crate::public_inode_id")]
875    pub inode_id: InodeId,
876    /// Revision number within the file inode.
877    pub revision_no: RevisionNo,
878    /// Namespace sequence that created this revision.
879    pub committed_seq: ChangeSeq,
880    /// Commit ID for this revision.
881    pub commit_id: CommitId,
882    /// The commit time in Unix milliseconds; `committed_seq` defines commit order.
883    pub committed_at_ms: u64,
884    /// Actor responsible for this revision, as supplied by the application.
885    pub committed_by: crate::ActorId,
886    /// Content stored for this revision.
887    pub content_ref: ContentRef,
888}
889
890/// Response for listing file revisions.
891#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
892#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
893pub struct ListFileRevisionsResponse {
894    /// Namespace that was read.
895    pub namespace_id: NamespaceId,
896    /// File inode whose revisions were returned.
897    #[serde(with = "crate::public_inode_id")]
898    pub inode_id: InodeId,
899    /// Namespace head sequence used for the read.
900    pub head_seq: ChangeSeq,
901    /// Retained revisions in order.
902    pub revisions: Vec<FileRevision>,
903    /// Opaque cursor for the next page, if more revisions are available.
904    #[serde(default, skip_serializing_if = "Option::is_none")]
905    #[cfg_attr(feature = "openapi", schema(nullable = false))]
906    pub next_cursor: Option<String>,
907}
908
909/// Request to create a durable checkpoint pin.
910#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
911#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
912#[serde(deny_unknown_fields)]
913pub struct CreateCheckpointRequest {
914    /// The non-unique label recorded on the checkpoint.
915    pub name: String,
916    /// The checkpoint lifetime in milliseconds, or `None` for an explicit deletion only.
917    #[serde(default, skip_serializing_if = "Option::is_none")]
918    #[cfg_attr(feature = "openapi", schema(nullable = false))]
919    pub ttl_ms: Option<u64>,
920}
921
922/// Request to create a snapshot.
923#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
924#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
925#[serde(deny_unknown_fields)]
926pub struct CreateSnapshotRequest {
927    /// A label that does not need to be unique.
928    pub name: String,
929    /// Snapshot lifetime from the current server time, in milliseconds.
930    pub ttl_ms: u64,
931}
932
933/// Request to extend a read snapshot.
934#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
935#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
936#[serde(deny_unknown_fields)]
937pub struct ExtendSnapshotRequest {
938    /// Requested lifetime from the server's current time, in milliseconds.
939    pub ttl_ms: u64,
940}
941
942/// Identifies the checkpoint record that was deleted.
943#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
944#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
945pub struct DeleteCheckpointResponse {
946    /// Namespace the checkpoint belonged to.
947    pub namespace_id: NamespaceId,
948    /// Deleted checkpoint record.
949    pub checkpoint_id: CheckpointId,
950}
951
952/// The owner of a checkpoint record.
953#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
954#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
955#[serde(tag = "kind", rename_all = "snake_case")]
956pub enum CheckpointOwnerSummary {
957    /// An operator-created pin, deleted by id or by its own expiry.
958    #[cfg_attr(feature = "openapi", schema(title = "CheckpointOwnerUser"))]
959    User {
960        /// The non-unique label recorded by the creator.
961        name: String,
962    },
963    /// A fork target retaining its source basis for one fork attempt.
964    #[cfg_attr(feature = "openapi", schema(title = "CheckpointOwnerFork"))]
965    Fork {
966        /// The target namespace whose existence retains this checkpoint.
967        target_namespace_id: NamespaceId,
968    },
969    /// An application-created read view.
970    #[cfg_attr(feature = "openapi", schema(title = "CheckpointOwnerSnapshot"))]
971    Snapshot {
972        /// A label that does not need to be unique.
973        name: String,
974    },
975}
976
977/// One checkpoint resource described by its durable record.
978#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
979#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
980pub struct Checkpoint {
981    /// Namespace that owns the checkpoint.
982    pub namespace_id: NamespaceId,
983    /// Durable checkpoint id used to address the checkpoint for deletion.
984    pub checkpoint_id: CheckpointId,
985    /// Who owns the checkpoint, including the label carried by a user pin.
986    pub owner: CheckpointOwnerSummary,
987    /// Time the checkpoint record was created, in Unix milliseconds.
988    pub created_at_ms: u64,
989    /// Expiry in Unix milliseconds; collection waits one further grace window.
990    #[serde(default, skip_serializing_if = "Option::is_none")]
991    #[cfg_attr(feature = "openapi", schema(nullable = false))]
992    pub expires_at_ms: Option<u64>,
993    /// Namespace sequence captured by the checkpoint.
994    pub captured_seq: ChangeSeq,
995    /// Manifest pinned by the checkpoint.
996    pub manifest_no: ManifestNo,
997}
998
999/// A live snapshot.
1000#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1001#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1002#[cfg_attr(feature = "openapi", schema(as = Snapshot))]
1003pub struct SnapshotSummary {
1004    /// Snapshot id.
1005    pub snapshot_id: SnapshotId,
1006    /// Namespace whose state the snapshot captured.
1007    pub namespace_id: NamespaceId,
1008    /// Snapshot label.
1009    pub name: String,
1010    /// Namespace sequence captured by the snapshot.
1011    pub captured_seq: ChangeSeq,
1012    /// Time the snapshot record was created, in Unix milliseconds.
1013    pub created_at_ms: u64,
1014    /// When the snapshot expires, in Unix milliseconds.
1015    pub expires_at_ms: u64,
1016}
1017
1018impl SnapshotSummary {
1019    /// Converts a snapshot-owned checkpoint to a snapshot summary.
1020    ///
1021    /// Returns `None` for another owner. A snapshot owner always carries the
1022    /// checkpoint's top-level `expires_at_ms`.
1023    pub fn from_checkpoint(checkpoint: Checkpoint) -> Option<Self> {
1024        let CheckpointOwnerSummary::Snapshot { name } = checkpoint.owner else {
1025            return None;
1026        };
1027        Some(Self {
1028            snapshot_id: checkpoint.checkpoint_id.into(),
1029            namespace_id: checkpoint.namespace_id,
1030            name,
1031            captured_seq: checkpoint.captured_seq,
1032            created_at_ms: checkpoint.created_at_ms,
1033            expires_at_ms: checkpoint.expires_at_ms?,
1034        })
1035    }
1036}
1037
1038/// One page of active checkpoint records.
1039#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1040#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1041pub struct ListCheckpointsResponse {
1042    /// Namespace the records belong to.
1043    pub namespace_id: NamespaceId,
1044    /// The active records in ascending checkpoint ID order.
1045    pub checkpoints: Vec<Checkpoint>,
1046    /// Opaque cursor for the next page.
1047    #[serde(default, skip_serializing_if = "Option::is_none")]
1048    #[cfg_attr(feature = "openapi", schema(nullable = false))]
1049    pub next_cursor: Option<String>,
1050}
1051
1052/// One page of live read snapshots.
1053#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1054#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1055pub struct ListSnapshotsResponse {
1056    /// Namespace the snapshots belong to.
1057    pub namespace_id: NamespaceId,
1058    /// Live snapshot records in ascending snapshot-id order.
1059    pub snapshots: Vec<SnapshotSummary>,
1060    /// Opaque cursor for the next page.
1061    #[serde(default, skip_serializing_if = "Option::is_none")]
1062    #[cfg_attr(feature = "openapi", schema(nullable = false))]
1063    pub next_cursor: Option<String>,
1064}
1065
1066/// Identifies the snapshot record that was deleted.
1067#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1068#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1069pub struct DeleteSnapshotResponse {
1070    /// Namespace the snapshot belonged to.
1071    pub namespace_id: NamespaceId,
1072    /// Deleted snapshot record.
1073    pub snapshot_id: SnapshotId,
1074}
1075
1076/// How one WAL flush satisfied its goal.
1077#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1078#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1079#[serde(rename_all = "snake_case")]
1080pub enum FlushWalOutcome {
1081    /// The current manifest already covered the WAL tail; nothing was published.
1082    AlreadyCurrent,
1083    /// This call published the next current manifest.
1084    Published,
1085    /// Another publisher changed the current manifest before this call could publish.
1086    ManifestAdvanced,
1087}
1088
1089/// The current manifest state after one WAL flush.
1090#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1091#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1092pub struct FlushWalResponse {
1093    /// Namespace whose WAL tail was flushed.
1094    pub namespace_id: NamespaceId,
1095    /// Head sequence the flush attempted to cover.
1096    pub target_head_seq: ChangeSeq,
1097    /// Current manifest number after the operation.
1098    pub manifest_no: ManifestNo,
1099    /// Sequence covered by that manifest.
1100    pub manifest_head_seq: ChangeSeq,
1101    /// Whether this call published the current manifest.
1102    pub outcome: FlushWalOutcome,
1103}
1104
1105/// Optional overrides for one garbage-collection pass.
1106///
1107/// Unknown fields are rejected.
1108#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1109#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1110#[serde(deny_unknown_fields)]
1111pub struct GcRequest {
1112    /// The minimum object age for deletion in milliseconds, which must meet the
1113    /// server's advertised safety floor.
1114    #[serde(default, skip_serializing_if = "Option::is_none")]
1115    #[cfg_attr(feature = "openapi", schema(nullable = false))]
1116    pub grace_window_ms: Option<u64>,
1117}
1118
1119/// The candidates inspected but not deleted by one garbage-collection pass.
1120#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1121#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1122pub struct RetainedCandidates {
1123    /// Candidates protected by current references or manifest discovery.
1124    pub referenced: u64,
1125    /// Unreachable candidates younger than the grace window by their provider timestamps.
1126    pub within_grace_window: u64,
1127    /// Unreachable candidates without provider timestamps.
1128    pub no_provider_timestamp: u64,
1129    /// Unrecognized keys retained from object families scanned by garbage collection.
1130    pub unrecognized_key: u64,
1131    /// Checkpoint records whose owner or grace window prevents deletion.
1132    pub checkpoint_not_deletable: u64,
1133    /// Upload sessions still protected by a lease or grace window.
1134    pub upload_session_window: u64,
1135    /// Upload sessions whose deletion safety could not be determined.
1136    pub upload_session_undecided: u64,
1137}
1138
1139/// Object counts deleted by one garbage-collection pass, grouped by family.
1140#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1141#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1142pub struct DeletedObjectCounts {
1143    /// Unreferenced WAL segments deleted.
1144    pub wal_segments: u64,
1145    /// Unreferenced metadata segments deleted.
1146    pub metadata_segments: u64,
1147    /// Unreferenced manifests deleted.
1148    pub manifests: u64,
1149    /// Upload-session control objects deleted after the reap window.
1150    pub upload_sessions: u64,
1151    /// Content reclaimed through completed upload sessions.
1152    pub content_objects: u64,
1153    /// Successful deletion attempts under a retired namespace owner prefix.
1154    pub retired_content_objects: u64,
1155}
1156
1157impl DeletedObjectCounts {
1158    /// Adds counts from another pass.
1159    pub fn add(&mut self, other: &Self) {
1160        let Self {
1161            wal_segments,
1162            metadata_segments,
1163            manifests,
1164            upload_sessions,
1165            content_objects,
1166            retired_content_objects,
1167        } = other;
1168        self.wal_segments += wal_segments;
1169        self.metadata_segments += metadata_segments;
1170        self.manifests += manifests;
1171        self.upload_sessions += upload_sessions;
1172        self.content_objects += content_objects;
1173        self.retired_content_objects += retired_content_objects;
1174    }
1175}
1176
1177/// Checkpoint record counts deleted by one garbage-collection pass, grouped by owner.
1178#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1179#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1180pub struct DeletedCheckpointsByOwner {
1181    /// Fork-owned records deleted because their target namespaces are gone.
1182    pub fork: u64,
1183    /// User-owned records deleted after expiry or terminal namespace deletion.
1184    pub expired: u64,
1185    /// Snapshot-owned records deleted after expiry or terminal namespace deletion.
1186    pub snapshot: u64,
1187}
1188
1189impl DeletedCheckpointsByOwner {
1190    /// Adds counts from another pass.
1191    pub fn add(&mut self, other: &Self) {
1192        let Self {
1193            fork,
1194            expired,
1195            snapshot,
1196        } = other;
1197        self.fork += fork;
1198        self.expired += expired;
1199        self.snapshot += snapshot;
1200    }
1201}
1202
1203/// The result of one stateless garbage-collection call.
1204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1205#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1206pub struct GcResponse {
1207    /// Namespace the pass ran against.
1208    pub namespace_id: NamespaceId,
1209    /// Objects the pass deleted, split by object family.
1210    pub deleted: DeletedObjectCounts,
1211    /// The checkpoint records deleted by the pass, grouped by owner.
1212    pub deleted_checkpoints_by_owner: DeletedCheckpointsByOwner,
1213    /// Candidates retained at deletion time, grouped by reason.
1214    pub retained: RetainedCandidates,
1215    /// The earliest known future reclamation time observed by this pass.
1216    #[serde(default, skip_serializing_if = "Option::is_none")]
1217    #[cfg_attr(feature = "openapi", schema(nullable = false))]
1218    pub next_reclamation_at_ms: Option<u64>,
1219    /// The deleted head's irrevocable owner-prefix collection deadline.
1220    #[serde(default, skip_serializing_if = "Option::is_none")]
1221    #[cfg_attr(feature = "openapi", schema(nullable = false))]
1222    pub reclaim_after_ms: Option<u64>,
1223}
1224
1225impl GcResponse {
1226    /// An empty report for `namespace_id`, before any candidate is examined.
1227    pub fn empty(namespace_id: NamespaceId) -> Self {
1228        Self {
1229            namespace_id,
1230            deleted: DeletedObjectCounts::default(),
1231            deleted_checkpoints_by_owner: DeletedCheckpointsByOwner::default(),
1232            retained: RetainedCandidates::default(),
1233            next_reclamation_at_ms: None,
1234            reclaim_after_ms: None,
1235        }
1236    }
1237
1238    /// Records one retained candidate under the reason that spared it.
1239    pub fn retain(&mut self, reason: RetainedReason) {
1240        *reason.counter(&mut self.retained) += 1;
1241    }
1242}
1243
1244/// The reason one candidate was retained and the corresponding [`RetainedCandidates`] field.
1245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1246pub enum RetainedReason {
1247    /// Counts into [`RetainedCandidates::referenced`].
1248    Referenced,
1249    /// Counts into [`RetainedCandidates::within_grace_window`].
1250    WithinGraceWindow,
1251    /// Counts into [`RetainedCandidates::no_provider_timestamp`].
1252    NoProviderTimestamp,
1253    /// Counts into [`RetainedCandidates::unrecognized_key`].
1254    UnrecognizedKey,
1255    /// Counts into [`RetainedCandidates::checkpoint_not_deletable`].
1256    CheckpointNotDeletable,
1257    /// Counts into [`RetainedCandidates::upload_session_window`].
1258    UploadSessionWindow,
1259    /// Counts into [`RetainedCandidates::upload_session_undecided`].
1260    UploadSessionUndecided,
1261}
1262
1263impl RetainedReason {
1264    fn counter(self, retained: &mut RetainedCandidates) -> &mut u64 {
1265        match self {
1266            Self::Referenced => &mut retained.referenced,
1267            Self::WithinGraceWindow => &mut retained.within_grace_window,
1268            Self::NoProviderTimestamp => &mut retained.no_provider_timestamp,
1269            Self::UnrecognizedKey => &mut retained.unrecognized_key,
1270            Self::CheckpointNotDeletable => &mut retained.checkpoint_not_deletable,
1271            Self::UploadSessionWindow => &mut retained.upload_session_window,
1272            Self::UploadSessionUndecided => &mut retained.upload_session_undecided,
1273        }
1274    }
1275}
1276
1277impl RetainedCandidates {
1278    /// Counts all candidates retained by the pass.
1279    pub fn total(&self) -> u64 {
1280        self.by_reason().into_iter().map(|(_, count)| count).sum()
1281    }
1282
1283    /// Returns every reason and count in a fixed order.
1284    pub(crate) fn by_reason(&self) -> [(&'static str, u64); 7] {
1285        let Self {
1286            referenced,
1287            within_grace_window,
1288            no_provider_timestamp,
1289            unrecognized_key,
1290            checkpoint_not_deletable,
1291            upload_session_window,
1292            upload_session_undecided,
1293        } = *self;
1294        [
1295            ("referenced", referenced),
1296            ("within_grace_window", within_grace_window),
1297            ("no_provider_timestamp", no_provider_timestamp),
1298            ("unrecognized_key", unrecognized_key),
1299            ("checkpoint_not_deletable", checkpoint_not_deletable),
1300            ("upload_session_window", upload_session_window),
1301            ("upload_session_undecided", upload_session_undecided),
1302        ]
1303    }
1304
1305    /// Adds counts from another pass.
1306    pub fn add(&mut self, other: &Self) {
1307        let Self {
1308            referenced,
1309            within_grace_window,
1310            no_provider_timestamp,
1311            unrecognized_key,
1312            checkpoint_not_deletable,
1313            upload_session_window,
1314            upload_session_undecided,
1315        } = other;
1316        self.referenced += referenced;
1317        self.within_grace_window += within_grace_window;
1318        self.no_provider_timestamp += no_provider_timestamp;
1319        self.unrecognized_key += unrecognized_key;
1320        self.checkpoint_not_deletable += checkpoint_not_deletable;
1321        self.upload_session_window += upload_session_window;
1322        self.upload_session_undecided += upload_session_undecided;
1323    }
1324
1325    /// The reason with the highest count, and that count. `None` when
1326    /// nothing was retained. Ties go to the first reason in the fixed table
1327    /// order, so one pass's report is stable.
1328    pub fn top_reason(&self) -> Option<(&'static str, u64)> {
1329        self.by_reason()
1330            .into_iter()
1331            .filter(|(_, count)| *count > 0)
1332            // `max_by_key` keeps the last of equal maxima, so the reversal
1333            // is what makes a tie report the earlier reason.
1334            .rev()
1335            .max_by_key(|(_, count)| *count)
1336    }
1337}
1338
1339/// An option-free request that selects retention-floor advancement.
1340#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1341#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1342#[serde(deny_unknown_fields)]
1343pub struct AdvanceRetentionRequest {}
1344
1345/// Result of advancing the retention floor.
1346#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1347#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1348pub struct AdvanceRetentionResponse {
1349    /// Namespace whose retention floor was advanced.
1350    pub namespace_id: NamespaceId,
1351    /// New minimum sequence for incremental replay.
1352    pub retention_floor_seq: ChangeSeq,
1353}
1354
1355/// One maintenance job for one namespace.
1356#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1357#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1358#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
1359pub enum RunMaintenanceRequest {
1360    /// Runs WAL flushing and one bounded metadata reorganization step.
1361    Metadata(MetadataMaintenanceRequest),
1362    /// Runs one full metadata compaction.
1363    MetadataCompaction(MetadataCompactionRequest),
1364    /// Collects aged, unreferenced objects.
1365    Gc(GcRequest),
1366    /// Advances the retention floor to the flushed manifest head.
1367    Retention(AdvanceRetentionRequest),
1368    /// Restores a root administrator.
1369    RecoverAdministrator(RecoverAdministratorRequest),
1370}
1371
1372/// Grants `admin` on the root row to one principal, keeping every other
1373/// root grant, through a commit that checks no subject.
1374#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1375#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1376#[serde(deny_unknown_fields)]
1377pub struct RecoverAdministratorRequest {
1378    /// Principal receiving administrator rights.
1379    pub principal_id: PrincipalId,
1380}
1381
1382/// The committed administrator recovery.
1383#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1384#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1385pub struct RecoverAdministratorResponse {
1386    /// Namespace whose root grants changed.
1387    pub namespace_id: NamespaceId,
1388    /// Recovery commit id.
1389    pub commit_id: CommitId,
1390    /// Sequence assigned to the recovery commit.
1391    pub committed_seq: ChangeSeq,
1392    /// Root access revision after recovery.
1393    pub access_revision_no: AccessRevisionNo,
1394}
1395
1396/// Overrides for the metadata-upkeep action.
1397#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1398#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1399#[serde(deny_unknown_fields)]
1400pub struct MetadataMaintenanceRequest {
1401    /// The WAL-tail threshold for flushing, or `None` for the server default.
1402    #[serde(default, skip_serializing_if = "Option::is_none")]
1403    #[cfg_attr(feature = "openapi", schema(nullable = false))]
1404    pub max_wal_tail_segments: Option<u64>,
1405}
1406
1407/// An option-free request that selects one full metadata compaction.
1408#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1409#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1410#[serde(deny_unknown_fields)]
1411pub struct MetadataCompactionRequest {}
1412
1413/// What the WAL-flush part of a maintenance pass did.
1414#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1415#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1416#[serde(tag = "outcome", rename_all = "snake_case")]
1417pub enum WalFlushStepOutcome {
1418    /// The tail was below the threshold, so there was nothing to flush.
1419    NotNeeded,
1420    /// The step flushed the WAL tail and published the next current manifest.
1421    Flushed {
1422        /// Sequence covered by the published manifest.
1423        manifest_head_seq: ChangeSeq,
1424    },
1425    /// The current manifest already covered the captured WAL tail; this step published no manifest.
1426    AlreadyPublished {
1427        /// Sequence this step attempted to flush through.
1428        attempted_seq: ChangeSeq,
1429        /// The namespace's current manifest number.
1430        current_manifest_no: ManifestNo,
1431    },
1432    /// Concurrent updates prevented every publication attempt.
1433    RetriesExhausted {
1434        /// Head sequence observed before the step ran.
1435        observed_head_seq: ChangeSeq,
1436    },
1437}
1438
1439/// The outcome of the metadata-reorganization part of a maintenance pass.
1440#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1441#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1442#[serde(tag = "outcome", rename_all = "snake_case")]
1443pub enum ReorganizeStepOutcome {
1444    /// No family group had enough delta runs to merge.
1445    #[cfg_attr(feature = "openapi", schema(title = "ReorganizeStepOutcomeNotNeeded"))]
1446    NotNeeded {},
1447    /// One family group was merged and a manifest published.
1448    #[cfg_attr(
1449        feature = "openapi",
1450        schema(title = "ReorganizeStepOutcomeUnitPublished")
1451    )]
1452    UnitPublished {},
1453    /// A family group needs a streaming compaction. Run the `metadata_compaction` job.
1454    #[cfg_attr(
1455        feature = "openapi",
1456        schema(title = "ReorganizeStepOutcomeCompactionRequired")
1457    )]
1458    CompactionRequired {},
1459    /// Another publisher changed the current manifest before this step could publish.
1460    #[cfg_attr(
1461        feature = "openapi",
1462        schema(title = "ReorganizeStepOutcomeManifestAdvanced")
1463    )]
1464    ManifestAdvanced {},
1465    /// A newer runtime holds the compactor epoch.
1466    #[cfg_attr(feature = "openapi", schema(title = "ReorganizeStepOutcomeFenced"))]
1467    Fenced {},
1468}
1469
1470/// The result of one maintenance job. The `kind` matches the request.
1471#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1472#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1473#[serde(tag = "kind", rename_all = "snake_case")]
1474pub enum RunMaintenanceResponse {
1475    /// Result of WAL flushing and one bounded metadata reorganization step.
1476    Metadata(MetadataMaintenanceResponse),
1477    /// Result of one full metadata compaction.
1478    MetadataCompaction(MetadataCompactionResponse),
1479    /// Counts and deadlines from one collection call.
1480    Gc(GcResponse),
1481    /// Result of advancing the retention floor.
1482    Retention(AdvanceRetentionResponse),
1483    /// The committed administrator recovery.
1484    RecoverAdministrator(RecoverAdministratorResponse),
1485}
1486
1487/// What one metadata-upkeep action did, part by part.
1488#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1489#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1490pub struct MetadataMaintenanceResponse {
1491    /// Namespace maintained by this run.
1492    pub namespace_id: NamespaceId,
1493    /// What the WAL flush did.
1494    pub wal_flush: WalFlushStepOutcome,
1495    /// What the reorganization unit did.
1496    pub reorganize: ReorganizeStepOutcome,
1497}
1498
1499/// What one metadata compaction run did.
1500#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1501#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1502pub struct MetadataCompactionResponse {
1503    /// Namespace compacted by this run.
1504    pub namespace_id: NamespaceId,
1505    /// The compaction outcome.
1506    pub compaction: MetadataCompactionOutcome,
1507}
1508
1509/// The outcome of one metadata compaction run.
1510#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1511#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1512#[serde(tag = "outcome", rename_all = "snake_case")]
1513pub enum MetadataCompactionOutcome {
1514    /// No eligible family group was available and nothing was published.
1515    NotNeeded,
1516    /// The selected window fit a bounded step and this run published it.
1517    BoundedMergePublished,
1518    /// The selected run window was replaced in a published manifest.
1519    Published {
1520        /// Manifest published by the compaction.
1521        manifest_no: ManifestNo,
1522        /// Rows read by the compaction.
1523        rows_read: u64,
1524        /// Rows written by the compaction.
1525        rows_written: u64,
1526        /// Input bytes read by the compaction.
1527        input_bytes: u64,
1528        /// Output bytes written by the compaction.
1529        output_bytes: u64,
1530        /// Output segments written by the compaction.
1531        output_segments: u64,
1532    },
1533    /// The run was cancelled; the manifest did not move.
1534    Cancelled,
1535    /// Inputs changed, time ran out, or publication retries were exhausted.
1536    Abandoned,
1537    /// Another process claimed the namespace compactor role; nothing was published.
1538    Fenced,
1539}
1540
1541/// An empty request for one store contract probe.
1542#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1543#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1544#[serde(deny_unknown_fields)]
1545pub struct StoreProbeRequest {}
1546
1547/// The ordered results from one store contract probe.
1548#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1549#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1550pub struct StoreProbeResponse {
1551    /// The server-generated label for this probe run and its objects.
1552    pub run_id: String,
1553    /// The check results in execution order.
1554    pub checks: Vec<StoreProbeCheckResult>,
1555}
1556
1557/// One named contract check and what the store did with it.
1558#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1559#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1560pub struct StoreProbeCheckResult {
1561    /// Stable check name.
1562    pub name: String,
1563    /// What the store did.
1564    pub outcome: StoreProbeCheckOutcome,
1565    /// The expected and actual behavior for a failed check.
1566    #[serde(default, skip_serializing_if = "Option::is_none")]
1567    #[cfg_attr(feature = "openapi", schema(nullable = false))]
1568    pub message: Option<String>,
1569}
1570
1571/// What one contract check concluded about the store.
1572#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1573#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1574#[serde(rename_all = "snake_case")]
1575pub enum StoreProbeCheckOutcome {
1576    /// The store behaved as the contract requires.
1577    Passed,
1578    /// The store does not support this optional capability.
1579    Unsupported,
1580    /// The store violated the contract or the operation failed.
1581    Failed,
1582}
1583
1584#[cfg(test)]
1585mod tests {
1586    use super::*;
1587    use crate::ContentId;
1588
1589    #[test]
1590    fn file_revision_provenance_fields_are_pinned_on_the_wire() {
1591        let content_ref = ContentRef::blob_v1(
1592            crate::NamespaceId::parse("demo").expect("namespace id"),
1593            crate::ContentId::generate(),
1594            b"hello",
1595        );
1596        let revision = FileRevision {
1597            inode_id: InodeId(2),
1598            revision_no: RevisionNo(3),
1599            committed_seq: ChangeSeq(7),
1600            commit_id: CommitId::parse("c_revision_owner").expect("commit id"),
1601            committed_at_ms: 1_752_624_000_000,
1602            committed_by: crate::ActorId::loonfs(),
1603            content_ref: content_ref.clone(),
1604        };
1605
1606        assert_eq!(
1607            serde_json::to_value(revision).expect("serialize file revision"),
1608            serde_json::json!({
1609                "inode_id": "ino_2",
1610                "revision_no": 3,
1611                "committed_seq": 7,
1612                "commit_id": "c_revision_owner",
1613                "committed_at_ms": 1_752_624_000_000_u64,
1614                "committed_by": "loonfs",
1615                "content_ref": content_ref,
1616            })
1617        );
1618    }
1619    fn path(value: &str) -> AbsolutePath {
1620        AbsolutePath::parse(value).expect("valid test path")
1621    }
1622
1623    fn attribute_key(value: &str) -> AttributeKey {
1624        AttributeKey::parse(value).expect("valid test attribute key")
1625    }
1626
1627    fn sample_content_ref() -> ContentRef {
1628        ContentRef::blob_v1(
1629            crate::NamespaceId::parse("demo").expect("namespace id"),
1630            ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("valid content id"),
1631            b"hello",
1632        )
1633    }
1634
1635    #[test]
1636    fn namespace_wire_shape_has_only_core_state() {
1637        let namespace = Namespace {
1638            access: NamespaceAccessMode::Unrestricted {},
1639            namespace_id: NamespaceId::parse("demo").expect("namespace id"),
1640            created_at_ms: 1_000,
1641            created_by: crate::ActorId::parse("test").expect("actor"),
1642            fork_basis: None,
1643            head_seq: ChangeSeq(11),
1644            retention_floor_seq: ChangeSeq(4),
1645        };
1646        assert_eq!(
1647            serde_json::to_value(namespace).expect("serialize namespace"),
1648            serde_json::json!({
1649                "namespace_id": "demo",
1650                "access": {"kind": "unrestricted"},
1651                "created_at_ms": 1000,
1652                "created_by": "test",
1653                "head_seq": 11,
1654                "retention_floor_seq": 4
1655            })
1656        );
1657    }
1658
1659    #[test]
1660    fn namespace_diagnostics_wire_shape_keeps_storage_fields() {
1661        let diagnostics = NamespaceDiagnostics {
1662            namespace_id: NamespaceId::parse("demo").expect("namespace id"),
1663            created_at_ms: 1_000,
1664            created_by: crate::ActorId::parse("test").expect("actor"),
1665            fork_basis: None,
1666            head_seq: ChangeSeq(11),
1667            retention_floor_seq: ChangeSeq(4),
1668            current_manifest_no: Some(ManifestNo(8)),
1669            wal_tail_segments: 3,
1670            live_snapshots: 2,
1671            live_checkpoints: 5,
1672        };
1673        assert_eq!(
1674            serde_json::to_value(diagnostics).expect("serialize namespace diagnostics"),
1675            serde_json::json!({
1676                "namespace_id": "demo",
1677                "created_at_ms": 1000,
1678                "created_by": "test",
1679                "head_seq": 11,
1680                "retention_floor_seq": 4,
1681                "current_manifest_no": 8,
1682                "wal_tail_segments": 3,
1683                "live_snapshots": 2,
1684                "live_checkpoints": 5
1685            })
1686        );
1687    }
1688
1689    #[test]
1690    fn behavior_enums_use_snake_case_wire_values() {
1691        assert_eq!(
1692            DestinationBehavior::default(),
1693            DestinationBehavior::NoReplace
1694        );
1695        assert_eq!(
1696            DeleteDirectoryBehavior::default(),
1697            DeleteDirectoryBehavior::NonRecursive
1698        );
1699        assert_eq!(
1700            serde_json::to_value(DestinationBehavior::NoReplace)
1701                .expect("destination behavior json"),
1702            serde_json::json!("no_replace")
1703        );
1704        assert_eq!(
1705            serde_json::to_value(DestinationBehavior::Replace).expect("destination behavior json"),
1706            serde_json::json!("replace")
1707        );
1708        assert_eq!(
1709            serde_json::to_value(DeleteDirectoryBehavior::NonRecursive)
1710                .expect("delete behavior json"),
1711            serde_json::json!("non_recursive")
1712        );
1713        assert_eq!(
1714            serde_json::to_value(DeleteDirectoryBehavior::Recursive).expect("delete behavior json"),
1715            serde_json::json!("recursive")
1716        );
1717    }
1718
1719    #[test]
1720    fn filesystem_delete_and_move_operations_use_behavior_field() {
1721        let create_directory = FilesystemOperation::CreateDirectory {
1722            path: path("/docs"),
1723            parents: false,
1724        };
1725        assert_eq!(
1726            serde_json::to_value(&create_directory).expect("create directory op json"),
1727            serde_json::json!({
1728                "kind": "create_directory",
1729                "path": "/docs"
1730            })
1731        );
1732
1733        let create_directory_with_parents = FilesystemOperation::CreateDirectory {
1734            path: path("/docs/notes"),
1735            parents: true,
1736        };
1737        assert_eq!(
1738            serde_json::to_value(&create_directory_with_parents)
1739                .expect("create directory with parents op json"),
1740            serde_json::json!({
1741                "kind": "create_directory",
1742                "path": "/docs/notes",
1743                "parents": true
1744            })
1745        );
1746
1747        let delete = FilesystemOperation::DeletePath {
1748            path: path("/docs"),
1749            behavior: DeleteDirectoryBehavior::Recursive,
1750            expected_inode_id: None,
1751        };
1752        assert_eq!(
1753            serde_json::to_value(&delete).expect("delete op json"),
1754            serde_json::json!({
1755                "kind": "delete_path",
1756                "path": "/docs",
1757                "behavior": "recursive"
1758            })
1759        );
1760
1761        let move_path = FilesystemOperation::MovePath {
1762            source_path: path("/docs/a.txt"),
1763            destination_path: path("/docs/b.txt"),
1764            precondition: crate::DestinationPrecondition {
1765                behavior: DestinationBehavior::Replace,
1766                expected_inode_id: Some(InodeId(7)),
1767                expected_revision_no: Some(RevisionNo(3)),
1768            },
1769        };
1770        assert_eq!(
1771            serde_json::to_value(&move_path).expect("move op json"),
1772            serde_json::json!({
1773                "kind": "move_path",
1774                "source_path": "/docs/a.txt",
1775                "destination_path": "/docs/b.txt",
1776                "behavior": "replace",
1777                "expected_destination_inode_id": "ino_7",
1778                "expected_destination_revision_no": 3
1779            })
1780        );
1781
1782        let copy_path = FilesystemOperation::CopyPath {
1783            source_path: path("/docs/a.txt"),
1784            destination_path: path("/docs/b.txt"),
1785            precondition: crate::DestinationPrecondition {
1786                behavior: DestinationBehavior::Replace,
1787                expected_inode_id: Some(InodeId(7)),
1788                expected_revision_no: Some(RevisionNo(3)),
1789            },
1790        };
1791        assert_eq!(
1792            serde_json::to_value(&copy_path).expect("copy op json"),
1793            serde_json::json!({
1794                "kind": "copy_path",
1795                "source_path": "/docs/a.txt",
1796                "destination_path": "/docs/b.txt",
1797                "behavior": "replace",
1798                "expected_destination_inode_id": "ino_7",
1799                "expected_destination_revision_no": 3
1800            })
1801        );
1802
1803        let update_attributes = FilesystemOperation::UpdateAttributes {
1804            path: path("/docs/a.txt"),
1805            set: BTreeMap::from([(
1806                attribute_key("owner"),
1807                AttributeValue::parse("ada").expect("valid attribute value"),
1808            )]),
1809            remove: vec![attribute_key("draft")],
1810            expected_inode_id: Some(InodeId(7)),
1811            expected_attributes_revision_no: Some(AttributeRevisionNo(3)),
1812        };
1813        assert_eq!(
1814            serde_json::to_value(&update_attributes).expect("update attributes op json"),
1815            serde_json::json!({
1816                "kind": "update_attributes",
1817                "path": "/docs/a.txt",
1818                "set": {"owner": "ada"},
1819                "remove": ["draft"],
1820                "expected_inode_id": "ino_7",
1821                "expected_attributes_revision_no": 3
1822            })
1823        );
1824    }
1825
1826    #[test]
1827    fn update_attributes_omits_empty_collections_and_absent_preconditions() {
1828        let set_only = FilesystemOperation::UpdateAttributes {
1829            path: path("/docs/a.txt"),
1830            set: BTreeMap::from([(
1831                attribute_key("owner"),
1832                AttributeValue::parse("ada,grace").expect("valid attribute value"),
1833            )]),
1834            remove: Vec::new(),
1835            expected_inode_id: None,
1836            expected_attributes_revision_no: None,
1837        };
1838        assert_eq!(
1839            serde_json::to_value(&set_only).expect("set-only op json"),
1840            serde_json::json!({
1841                "kind": "update_attributes",
1842                "path": "/docs/a.txt",
1843                "set": {"owner": "ada,grace"}
1844            })
1845        );
1846
1847        let decoded: FilesystemOperation = serde_json::from_value(serde_json::json!({
1848            "kind": "update_attributes",
1849            "path": "/docs/a.txt",
1850            "remove": ["draft"]
1851        }))
1852        .expect("remove-only op defaults the set map and both preconditions");
1853        assert_eq!(
1854            decoded,
1855            FilesystemOperation::UpdateAttributes {
1856                path: path("/docs/a.txt"),
1857                set: BTreeMap::new(),
1858                remove: vec![attribute_key("draft")],
1859                expected_inode_id: None,
1860                expected_attributes_revision_no: None,
1861            }
1862        );
1863    }
1864
1865    #[test]
1866    fn update_attributes_validates_keys_and_values_during_deserialization() {
1867        // The key grammar and the value shape are enforced on the way in, so
1868        // a malformed update never reaches planning.
1869        for encoded in [
1870            serde_json::json!({
1871                "kind": "update_attributes",
1872                "path": "/docs/a.txt",
1873                "set": {"": "ada"}
1874            }),
1875            serde_json::json!({
1876                "kind": "update_attributes",
1877                "path": "/docs/a.txt",
1878                "set": {"owner": {"kind": "string", "value": "ada"}}
1879            }),
1880            serde_json::json!({
1881                "kind": "update_attributes",
1882                "path": "/docs/a.txt",
1883                "remove": ["a\u{0}b"]
1884            }),
1885        ] {
1886            assert!(serde_json::from_value::<FilesystemOperation>(encoded).is_err());
1887        }
1888    }
1889
1890    #[test]
1891    fn filesystem_operations_default_omitted_behavior_fields() {
1892        let put: FilesystemOperation = serde_json::from_value(serde_json::json!({
1893            "kind": "put_file",
1894            "path": "/docs/a.txt",
1895            "content_ref": {
1896                "kind": "blob_v1",
1897                "owner_namespace_id": "demo",
1898                "content_id": "con_0123456789abcdef0123456789abcdef",
1899                "size_bytes": 1,
1900                "checksum": {
1901                    "algorithm": "sha256",
1902                    "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1903                }
1904            }
1905        }))
1906        .expect("put op defaults behavior");
1907        assert!(matches!(
1908            put,
1909            FilesystemOperation::PutFile {
1910                behavior: DestinationBehavior::NoReplace,
1911                expected_inode_id: None,
1912                expected_revision_no: None,
1913                ..
1914            }
1915        ));
1916
1917        let delete: FilesystemOperation = serde_json::from_value(serde_json::json!({
1918            "kind": "delete_path",
1919            "path": "/docs"
1920        }))
1921        .expect("delete op defaults behavior");
1922        assert_eq!(
1923            delete,
1924            FilesystemOperation::DeletePath {
1925                path: path("/docs"),
1926                behavior: DeleteDirectoryBehavior::NonRecursive,
1927                expected_inode_id: None,
1928            }
1929        );
1930
1931        let move_path: FilesystemOperation = serde_json::from_value(serde_json::json!({
1932            "kind": "move_path",
1933            "source_path": "/docs/a.txt",
1934            "destination_path": "/docs/b.txt"
1935        }))
1936        .expect("move op defaults behavior");
1937        assert_eq!(
1938            move_path,
1939            FilesystemOperation::MovePath {
1940                source_path: path("/docs/a.txt"),
1941                destination_path: path("/docs/b.txt"),
1942                precondition: crate::DestinationPrecondition {
1943                    behavior: DestinationBehavior::NoReplace,
1944                    expected_inode_id: None,
1945                    expected_revision_no: None,
1946                },
1947            }
1948        );
1949
1950        let copy_path: FilesystemOperation = serde_json::from_value(serde_json::json!({
1951            "kind": "copy_path",
1952            "source_path": "/docs/a.txt",
1953            "destination_path": "/docs/b.txt"
1954        }))
1955        .expect("copy op defaults behavior");
1956        assert_eq!(
1957            copy_path,
1958            FilesystemOperation::CopyPath {
1959                source_path: path("/docs/a.txt"),
1960                destination_path: path("/docs/b.txt"),
1961                precondition: crate::DestinationPrecondition {
1962                    behavior: DestinationBehavior::NoReplace,
1963                    expected_inode_id: None,
1964                    expected_revision_no: None,
1965                },
1966            }
1967        );
1968
1969        let move_by_inode: FilesystemOperation = serde_json::from_value(serde_json::json!({
1970            "kind": "move_by_inode",
1971            "inode_id": "ino_7",
1972            "expected_binding_generation": "aaaa",
1973            "destination_parent_inode_id": "ino_1",
1974            "destination_display_name": "b.txt"
1975        }))
1976        .expect("inode move defaults behavior");
1977        assert_eq!(
1978            move_by_inode,
1979            FilesystemOperation::MoveByInode {
1980                inode_id: InodeId(7),
1981                expected_binding_generation: BindingGeneration::parse("aaaa")
1982                    .expect("binding generation"),
1983                destination_parent_inode_id: InodeId(1),
1984                destination_display_name: DisplayName::parse("b.txt").expect("display name"),
1985                precondition: DestinationPrecondition::default(),
1986            }
1987        );
1988    }
1989
1990    #[test]
1991    fn filesystem_operation_paths_keep_the_plain_string_wire_shape() {
1992        let content_ref = ContentRef::blob_v1(
1993            crate::NamespaceId::parse("demo").expect("namespace id"),
1994            ContentId::generate(),
1995            b"hello",
1996        );
1997        let cases = [
1998            (
1999                FilesystemOperation::PutFile {
2000                    path: path("/docs/a.txt"),
2001                    content_ref: Some(content_ref.clone()),
2002                    inline_content: None,
2003                    behavior: DestinationBehavior::NoReplace,
2004                    expected_inode_id: None,
2005                    expected_revision_no: None,
2006                },
2007                serde_json::json!({
2008                    "kind": "put_file",
2009                    "path": "/docs/a.txt",
2010                    "content_ref": content_ref,
2011                    "behavior": "no_replace"
2012                }),
2013            ),
2014            (
2015                FilesystemOperation::Undelete {
2016                    inode_id: InodeId(7),
2017                    deletion_seq: ChangeSeq(8),
2018                    destination_path: Some(path("/docs/restored")),
2019                },
2020                serde_json::json!({
2021                    "kind": "undelete",
2022                    "inode_id": "ino_7",
2023                    "deletion_seq": 8,
2024                    "destination_path": "/docs/restored"
2025                }),
2026            ),
2027            (
2028                FilesystemOperation::RestoreRevision {
2029                    path: path("/docs/a.txt"),
2030                    source_revision_no: RevisionNo(2),
2031                },
2032                serde_json::json!({
2033                    "kind": "restore_revision",
2034                    "path": "/docs/a.txt",
2035                    "source_revision_no": 2
2036                }),
2037            ),
2038            (
2039                FilesystemOperation::UpdateAttributes {
2040                    path: path("/docs/a.txt"),
2041                    set: BTreeMap::new(),
2042                    remove: vec![attribute_key("draft")],
2043                    expected_inode_id: None,
2044                    expected_attributes_revision_no: None,
2045                },
2046                serde_json::json!({
2047                    "kind": "update_attributes",
2048                    "path": "/docs/a.txt",
2049                    "remove": ["draft"]
2050                }),
2051            ),
2052        ];
2053
2054        for (operation, string_shaped_json) in cases {
2055            assert_eq!(
2056                serde_json::to_value(operation).expect("serialize filesystem operation"),
2057                string_shaped_json
2058            );
2059        }
2060    }
2061
2062    #[test]
2063    fn filesystem_operation_paths_validate_during_deserialization() {
2064        for encoded in [
2065            serde_json::json!({"kind": "create_directory", "path": "relative", "parents": false}),
2066            serde_json::json!({
2067                "kind": "put_file",
2068                "path": "relative",
2069                "content_ref": ContentRef::blob_v1(crate::NamespaceId::parse("demo").expect("namespace id"), ContentId::generate(), b"hello")
2070            }),
2071            serde_json::json!({"kind": "delete_path", "path": "relative"}),
2072            serde_json::json!({
2073                "kind": "move_path",
2074                "source_path": "relative",
2075                "destination_path": "/target"
2076            }),
2077            serde_json::json!({
2078                "kind": "copy_path",
2079                "source_path": "/source",
2080                "destination_path": "relative"
2081            }),
2082            serde_json::json!({
2083                "kind": "undelete",
2084                "inode_id": "ino_7",
2085                "deletion_seq": 8,
2086                "destination_path": "relative"
2087            }),
2088            serde_json::json!({
2089                "kind": "restore_revision",
2090                "path": "relative",
2091                "source_revision_no": 2
2092            }),
2093            serde_json::json!({
2094                "kind": "update_attributes",
2095                "path": "relative",
2096                "remove": ["draft"]
2097            }),
2098        ] {
2099            assert!(serde_json::from_value::<FilesystemOperation>(encoded).is_err());
2100        }
2101    }
2102
2103    #[test]
2104    fn inode_request_fields_accept_only_the_public_format() {
2105        let operations = [
2106            serde_json::json!({
2107                "kind": "delete_path",
2108                "path": "/docs/a.txt",
2109                "expected_inode_id": "ino_27"
2110            }),
2111            serde_json::json!({
2112                "kind": "undelete",
2113                "inode_id": "ino_27",
2114                "deletion_seq": 8,
2115                "destination_path": "/docs/restored"
2116            }),
2117            serde_json::json!({
2118                "kind": "update_attributes",
2119                "path": "/docs/a.txt",
2120                "expected_inode_id": "ino_27"
2121            }),
2122        ];
2123
2124        for operation in operations {
2125            serde_json::from_value::<FilesystemOperation>(operation.clone())
2126                .expect("valid public inode ID");
2127
2128            let inode_key = if operation["kind"] == "undelete" {
2129                "inode_id"
2130            } else {
2131                "expected_inode_id"
2132            };
2133            for invalid in [serde_json::json!(27), serde_json::json!("27")] {
2134                let mut invalid_operation = operation.clone();
2135                invalid_operation[inode_key] = invalid;
2136                assert!(
2137                    serde_json::from_value::<FilesystemOperation>(invalid_operation).is_err(),
2138                    "{inode_key} accepted an invalid inode ID"
2139                );
2140            }
2141        }
2142    }
2143
2144    #[test]
2145    fn path_preconditions_reject_ambiguous_shapes() {
2146        let missing = serde_json::from_value::<CommitPrecondition>(
2147            serde_json::json!({"kind": "path_binding", "path": "/docs/input"}),
2148        )
2149        .expect_err("binding requires an inode");
2150        assert!(
2151            missing.to_string().contains("expected_inode_id"),
2152            "{missing}"
2153        );
2154        serde_json::from_value::<CommitPrecondition>(serde_json::json!({
2155            "kind": "path_binding", "path": "/docs/input", "expected_inode_id": null
2156        }))
2157        .expect_err("a null inode is not an absence check");
2158        let error = serde_json::from_value::<CommitPrecondition>(serde_json::json!({
2159            "kind": "path_absence", "path": "/docs/input", "expected_inode_id": "ino_42"
2160        }))
2161        .expect_err("absence accepts only a path");
2162        assert!(
2163            error
2164                .to_string()
2165                .contains("unknown field `expected_inode_id`"),
2166            "{error}"
2167        );
2168    }
2169
2170    #[test]
2171    fn a_misspelled_precondition_does_not_decode() {
2172        let put = |precondition: &str| {
2173            let mut operation = serde_json::json!({
2174                "kind": "put_file",
2175                "path": "/docs/a.txt",
2176                "content_ref": sample_content_ref(),
2177                "behavior": "replace",
2178                "expected_inode_id": "ino_7"
2179            });
2180            operation[precondition] = serde_json::json!(3);
2181            serde_json::json!({
2182                "commit_id": "with_preconditions-put",
2183                "operations": [operation]
2184            })
2185        };
2186
2187        let spelled: CommitRequest = serde_json::from_value(put("expected_revision_no"))
2188            .expect("the precondition spelled correctly decodes");
2189        assert!(matches!(
2190            spelled.operations.as_slice(),
2191            [FilesystemOperation::PutFile {
2192                expected_revision_no: Some(RevisionNo(3)),
2193                ..
2194            }]
2195        ));
2196
2197        for misspelling in ["expected_revsion_no", "expectedRevisionNo"] {
2198            assert!(
2199                serde_json::from_value::<CommitRequest>(put(misspelling)).is_err(),
2200                "`{misspelling}` decoded instead of failing the request"
2201            );
2202        }
2203    }
2204
2205    #[test]
2206    fn expected_revision_no_must_fit_the_public_integer_range() {
2207        let body = |expected_revision_no: u64| {
2208            serde_json::json!({
2209                "commit_id": "bounded-revision-precondition",
2210                "operations": [{
2211                    "kind": "put_file",
2212                    "path": "/docs/a.txt",
2213                    "content_ref": sample_content_ref(),
2214                    "behavior": "replace",
2215                    "expected_inode_id": "ino_7",
2216                    "expected_revision_no": expected_revision_no
2217                }]
2218            })
2219        };
2220
2221        let request: CommitRequest = serde_json::from_value(body(crate::MAX_PUBLIC_INTEGER))
2222            .expect("deserialize the maximum revision number");
2223        assert!(matches!(
2224            request.operations.as_slice(),
2225            [FilesystemOperation::PutFile {
2226                expected_revision_no: Some(RevisionNo(value)),
2227                ..
2228            }] if *value == crate::MAX_PUBLIC_INTEGER
2229        ));
2230
2231        let error = serde_json::from_value::<CommitRequest>(body(crate::MAX_PUBLIC_INTEGER + 1))
2232            .expect_err("reject a revision number above the public limit");
2233        assert!(
2234            error
2235                .to_string()
2236                .contains("must be an integer from 0 through 9007199254740991"),
2237            "unexpected range error: {error}"
2238        );
2239    }
2240
2241    #[test]
2242    fn a_commit_request_rejects_unknown_fields_at_every_level() {
2243        let valid = || {
2244            serde_json::json!({
2245                "commit_id": "strict-commit",
2246                "content_tokens": [{
2247                    "content_ref": sample_content_ref(),
2248                    "token": "opaque-proof"
2249                }],
2250                "operations": [{
2251                    "kind": "update_attributes",
2252                    "path": "/docs/a.txt",
2253                    "set": {"owner": "ada"},
2254                    "expected_inode_id": "ino_7"
2255                }]
2256            })
2257        };
2258        serde_json::from_value::<CommitRequest>(valid())
2259            .expect("the same body without a typo decodes");
2260
2261        let mut at_root = valid();
2262        at_root["mesage"] = serde_json::json!("a note");
2263
2264        let mut in_operation = valid();
2265        in_operation["operations"][0]["expectedAttributesRevisionNo"] = serde_json::json!(3);
2266
2267        let mut in_content_token = valid();
2268        in_content_token["content_tokens"][0]["expires_at_ms"] = serde_json::json!(1);
2269
2270        let mut in_content_ref = valid();
2271        in_content_ref["content_tokens"][0]["content_ref"]["sizeBytes"] = serde_json::json!(5);
2272
2273        for (level, body) in [
2274            ("the request root", at_root),
2275            ("an operation variant", in_operation),
2276            ("a nested content token", in_content_token),
2277            ("a content ref below that", in_content_ref),
2278        ] {
2279            assert!(
2280                serde_json::from_value::<CommitRequest>(body).is_err(),
2281                "an unknown field in {level} decoded instead of failing the request"
2282            );
2283        }
2284
2285        for (field, operation) in [
2286            (
2287                "path",
2288                serde_json::json!({
2289                    "kind": "undelete",
2290                    "inode_id": "ino_7",
2291                    "deletion_seq": 8,
2292                    "path": "/docs/restored"
2293                }),
2294            ),
2295            (
2296                "from_path",
2297                serde_json::json!({
2298                    "kind": "move_path",
2299                    "source_path": "/docs/a.txt",
2300                    "destination_path": "/docs/b.txt",
2301                    "from_path": "/docs/a.txt"
2302                }),
2303            ),
2304        ] {
2305            let mut body = valid();
2306            body["operations"] = serde_json::json!([operation]);
2307            let error = serde_json::from_value::<CommitRequest>(body)
2308                .expect_err("obsolete operation field must be rejected");
2309            assert!(
2310                error
2311                    .to_string()
2312                    .contains(&format!("unknown field `{field}`")),
2313                "{error}"
2314            );
2315        }
2316    }
2317
2318    #[test]
2319    fn checkpoint_responses_use_one_checkpoint_wire_object() {
2320        let namespace_id = NamespaceId::parse("demo").expect("namespace id");
2321        let checkpoint = Checkpoint {
2322            namespace_id: namespace_id.clone(),
2323            checkpoint_id: CheckpointId::parse("pin_00000000000000000001-0000000000000001")
2324                .expect("checkpoint id"),
2325            owner: CheckpointOwnerSummary::User {
2326                name: "release".to_owned(),
2327            },
2328            created_at_ms: 1_752_623_000_000,
2329            expires_at_ms: Some(1_752_626_600_000),
2330            captured_seq: ChangeSeq(12),
2331            manifest_no: ManifestNo(9),
2332        };
2333        let checkpoint_json = serde_json::json!({
2334            "namespace_id": "demo",
2335            "checkpoint_id": "pin_00000000000000000001-0000000000000001",
2336            "owner": {"kind": "user", "name": "release"},
2337            "created_at_ms": 1_752_623_000_000_u64,
2338            "expires_at_ms": 1_752_626_600_000_u64,
2339            "captured_seq": 12,
2340            "manifest_no": 9,
2341        });
2342        assert_eq!(
2343            serde_json::to_value(checkpoint.clone()).expect("serialize checkpoint"),
2344            checkpoint_json,
2345        );
2346        assert_eq!(
2347            serde_json::to_value(ListCheckpointsResponse {
2348                namespace_id: namespace_id.clone(),
2349                checkpoints: vec![checkpoint.clone()],
2350                next_cursor: None,
2351            })
2352            .expect("serialize list checkpoints response"),
2353            serde_json::json!({
2354                "namespace_id": "demo",
2355                "checkpoints": [checkpoint_json],
2356            }),
2357        );
2358        assert_eq!(
2359            serde_json::to_value(DeleteCheckpointResponse {
2360                namespace_id,
2361                checkpoint_id: checkpoint.checkpoint_id,
2362            })
2363            .expect("serialize delete checkpoint response"),
2364            serde_json::json!({
2365                "namespace_id": "demo",
2366                "checkpoint_id": "pin_00000000000000000001-0000000000000001",
2367            }),
2368        );
2369    }
2370
2371    #[test]
2372    fn optional_response_fields_are_omitted_and_default_when_absent() {
2373        let checkpoint_json = serde_json::to_value(Checkpoint {
2374            namespace_id: NamespaceId::parse("demo").expect("namespace id"),
2375            checkpoint_id: CheckpointId::parse("pin_00000000000000000001-0000000000000001")
2376                .expect("checkpoint id"),
2377            owner: CheckpointOwnerSummary::User {
2378                name: "release".to_owned(),
2379            },
2380            created_at_ms: 1_752_623_000_000,
2381            expires_at_ms: None,
2382            captured_seq: ChangeSeq(3),
2383            manifest_no: ManifestNo(3),
2384        })
2385        .expect("serialize checkpoint");
2386        assert!(checkpoint_json.get("expires_at_ms").is_none());
2387        let checkpoint: Checkpoint = serde_json::from_value(checkpoint_json)
2388            .expect("decode checkpoint without optional fields");
2389        assert_eq!(checkpoint.expires_at_ms, None);
2390
2391        let gc = GcResponse::empty(NamespaceId::parse("demo").expect("namespace id"));
2392        let gc_json = serde_json::to_value(gc).expect("serialize gc response");
2393        assert!(gc_json.get("next_reclamation_at_ms").is_none());
2394        assert!(gc_json.get("reclaim_after_ms").is_none());
2395        let gc: GcResponse =
2396            serde_json::from_value(gc_json).expect("decode gc response without optional fields");
2397        assert_eq!(gc.next_reclamation_at_ms, None);
2398        assert_eq!(gc.reclaim_after_ms, None);
2399        let retired = GcResponse {
2400            reclaim_after_ms: Some(2_000_000),
2401            ..gc
2402        };
2403        let json = serde_json::to_value(&retired).expect("encode retirement");
2404        assert_eq!(json["reclaim_after_ms"], 2_000_000);
2405        assert_eq!(
2406            serde_json::from_value::<GcResponse>(json).expect("decode retirement"),
2407            retired
2408        );
2409    }
2410
2411    #[test]
2412    fn maintenance_outcomes_use_the_outcome_tag() {
2413        assert_eq!(
2414            serde_json::to_value(WalFlushStepOutcome::Flushed {
2415                manifest_head_seq: ChangeSeq(9),
2416            })
2417            .expect("serialize WAL flush outcome"),
2418            serde_json::json!({"outcome": "flushed", "manifest_head_seq": 9})
2419        );
2420        assert_eq!(
2421            serde_json::to_value(ReorganizeStepOutcome::UnitPublished {})
2422                .expect("serialize reorganize outcome"),
2423            serde_json::json!({"outcome": "unit_published"})
2424        );
2425        assert_eq!(
2426            serde_json::to_value(RunMaintenanceResponse::MetadataCompaction(
2427                MetadataCompactionResponse {
2428                    namespace_id: NamespaceId::parse("demo").expect("namespace id"),
2429                    compaction: MetadataCompactionOutcome::Published {
2430                        manifest_no: ManifestNo(7),
2431                        rows_read: 11,
2432                        rows_written: 9,
2433                        input_bytes: 120,
2434                        output_bytes: 80,
2435                        output_segments: 2,
2436                    },
2437                },
2438            ))
2439            .expect("serialize metadata compaction response"),
2440            serde_json::json!({
2441                "kind": "metadata_compaction",
2442                "namespace_id": "demo",
2443                "compaction": {
2444                    "outcome": "published",
2445                    "manifest_no": 7,
2446                    "rows_read": 11,
2447                    "rows_written": 9,
2448                    "input_bytes": 120,
2449                    "output_bytes": 80,
2450                    "output_segments": 2
2451                }
2452            })
2453        );
2454    }
2455
2456    #[test]
2457    fn run_maintenance_requests_are_strict_and_round_trip() {
2458        let cases = [
2459            (
2460                serde_json::json!({"kind": "metadata"}),
2461                Some(RunMaintenanceRequest::Metadata(
2462                    MetadataMaintenanceRequest::default(),
2463                )),
2464            ),
2465            (
2466                serde_json::json!({"kind": "metadata", "max_wal_tail_segments": 4}),
2467                Some(RunMaintenanceRequest::Metadata(
2468                    MetadataMaintenanceRequest {
2469                        max_wal_tail_segments: Some(4),
2470                    },
2471                )),
2472            ),
2473            (
2474                serde_json::json!({"kind": "metadata_compaction"}),
2475                Some(RunMaintenanceRequest::MetadataCompaction(
2476                    MetadataCompactionRequest {},
2477                )),
2478            ),
2479            (
2480                serde_json::json!({"kind": "gc"}),
2481                Some(RunMaintenanceRequest::Gc(GcRequest::default())),
2482            ),
2483            (
2484                serde_json::json!({
2485                    "kind": "gc",
2486                    "grace_window_ms": 600_000
2487                }),
2488                Some(RunMaintenanceRequest::Gc(GcRequest {
2489                    grace_window_ms: Some(600_000),
2490                })),
2491            ),
2492            (
2493                serde_json::json!({"kind": "retention"}),
2494                Some(RunMaintenanceRequest::Retention(AdvanceRetentionRequest {})),
2495            ),
2496            (serde_json::json!({}), None),
2497            (serde_json::json!({"kind": "nope"}), None),
2498            (serde_json::json!({"kind": "gc", "bogus": 1}), None),
2499            (serde_json::json!({"kind": "gc", "max_objects": 1}), None),
2500            (serde_json::json!({"kind": "gc", "max_steps": 1}), None),
2501            (serde_json::json!({"kind": "retention", "bogus": 1}), None),
2502            (
2503                serde_json::json!({"kind": "metadata_compaction", "bogus": 1}),
2504                None,
2505            ),
2506        ];
2507
2508        for (body, expected) in cases {
2509            let decoded = serde_json::from_value::<RunMaintenanceRequest>(body.clone());
2510            match expected {
2511                Some(expected) => {
2512                    let decoded = decoded.expect("valid maintenance request should decode");
2513                    assert_eq!(decoded, expected);
2514                    assert_eq!(
2515                        serde_json::to_value(decoded)
2516                            .expect("maintenance request should serialize"),
2517                        body
2518                    );
2519                }
2520                None => assert!(
2521                    decoded.is_err(),
2522                    "invalid maintenance request decoded: {body}"
2523                ),
2524            }
2525        }
2526
2527        serde_json::from_value::<CreateCheckpointRequest>(
2528            serde_json::json!({"name": "nightly", "ttl_ms": 60_000}),
2529        )
2530        .expect("the same checkpoint body without a typo decodes");
2531        assert!(serde_json::from_value::<CreateCheckpointRequest>(
2532            serde_json::json!({"name": "nightly", "ttlMs": 60_000})
2533        )
2534        .is_err());
2535
2536        // The probe body carries no options yet, so an unknown one is the
2537        // only thing it can be sent.
2538        serde_json::from_value::<StoreProbeRequest>(serde_json::json!({}))
2539            .expect("an empty probe body decodes");
2540        assert!(
2541            serde_json::from_value::<StoreProbeRequest>(serde_json::json!({"deep": true})).is_err()
2542        );
2543
2544        serde_json::from_value::<CreateNamespaceRequest>(serde_json::json!({
2545            "namespace_id": "demo",
2546        }))
2547        .expect("the same create body without a typo decodes");
2548        assert!(
2549            serde_json::from_value::<CreateNamespaceRequest>(serde_json::json!({
2550                "namespace_id": "demo",
2551                "fork_of": "other"
2552            }))
2553            .is_err()
2554        );
2555        assert!(
2556            serde_json::from_value::<ForkNamespaceRequest>(serde_json::json!({
2557                "new_namespace_id": "demo",
2558                "source_namespace_id": "other"
2559            }))
2560            .is_err()
2561        );
2562    }
2563    #[test]
2564    fn update_access_round_trips_and_requires_boundary_and_grants() {
2565        let operation = FilesystemOperation::UpdateAccess {
2566            path: AbsolutePath::parse("/docs/secret").expect("path"),
2567            boundary: true,
2568            grants: serde_json::from_value(serde_json::json!({"prn_ada": ["read", "write"]}))
2569                .expect("grants"),
2570            expected_inode_id: Some(InodeId(9)),
2571            expected_access_revision_no: Some(AccessRevisionNo(2)),
2572        };
2573        let json = serde_json::json!({
2574            "kind": "update_access",
2575            "path": "/docs/secret",
2576            "boundary": true,
2577            "grants": {"prn_ada": ["read", "write"]},
2578            "expected_inode_id": "ino_9",
2579            "expected_access_revision_no": 2
2580        });
2581        assert_eq!(serde_json::to_value(&operation).expect("serialize"), json);
2582        assert_eq!(
2583            serde_json::from_value::<FilesystemOperation>(json.clone()).expect("decode"),
2584            operation
2585        );
2586        for field in ["boundary", "grants"] {
2587            let mut missing = json.clone();
2588            missing.as_object_mut().expect("object").remove(field);
2589            assert!(
2590                serde_json::from_value::<FilesystemOperation>(missing).is_err(),
2591                "missing {field}"
2592            );
2593        }
2594        assert_eq!(
2595            serde_json::to_value(CommitPrecondition::AccessRevision {
2596                inode_id: InodeId(9),
2597                expected_access_revision_no: AccessRevisionNo(2),
2598            })
2599            .expect("serialize precondition"),
2600            serde_json::json!({"kind": "access_revision", "inode_id": "ino_9", "expected_access_revision_no": 2})
2601        );
2602    }
2603}