Skip to main content

loonfs_api/
control.rs

1//! Durable control-object shapes: the head, metadata root, WAL floor,
2//! checkpoint records, upload sessions, and their envelopes (format spec,
3//! "Control objects").
4
5use crate::envelope::EnvelopeCodecError;
6use crate::WriterEpoch;
7use crate::{
8    ChangeSeq, CheckpointId, Checksum, ChecksumAlgorithm, CommitId, ContentId, ContentRef,
9    ContentRefKind, ContentStoreId, InodeId, ManifestId, ManifestObjectId, MetadataCompactionId,
10    NamespaceId, UploadId, WalSegmentId,
11};
12use serde::de::DeserializeOwned;
13use serde::{Deserialize, Deserializer, Serialize};
14use std::fmt;
15use std::num::NonZeroU64;
16
17/// Selects one independently versioned mutable control-object family.
18///
19/// See [mutable control-object rules](../../../docs/specs/format.md#17-mutable-control-object-rules).
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum ControlObjectKind {
23    /// Carries the sole live-visibility and writer-fencing authority.
24    WalHead,
25    /// Records the earliest sequence for which incremental history is retained.
26    WalFloor,
27    /// Points to the best known materialized metadata manifest.
28    MetadataRoot,
29    /// Pins a manifest basis for a user or fork lifecycle.
30    CheckpointRecord,
31    /// Tracks staged content through upload completion or cleanup.
32    UploadSession,
33    /// Marks one streaming metadata compaction's output as owned by a job
34    /// that is still running.
35    CompactionLease,
36}
37
38impl ControlObjectKind {
39    /// Lists every registered control-object family in stable registry order.
40    pub const ALL: [Self; 6] = [
41        Self::WalHead,
42        Self::WalFloor,
43        Self::MetadataRoot,
44        Self::CheckpointRecord,
45        Self::UploadSession,
46        Self::CompactionLease,
47    ];
48
49    /// Durable format version for this control object kind.
50    ///
51    /// Versions are tracked per kind so one kind's payload schema can make a
52    /// breaking change without invalidating every other control object.
53    /// Version 1 is a JSON envelope document carrying the current payload as
54    /// a raw JSON fragment whose checksum covers its exact bytes.
55    pub const fn format_version(self) -> u32 {
56        match self {
57            Self::WalHead => 1,
58            Self::WalFloor => 1,
59            Self::MetadataRoot => 1,
60            Self::CheckpointRecord => 1,
61            Self::UploadSession => 1,
62            Self::CompactionLease => 1,
63        }
64    }
65
66    /// Returns the frozen envelope discriminator for this control-object family.
67    pub const fn as_str(self) -> &'static str {
68        match self {
69            Self::WalHead => "wal_head",
70            Self::WalFloor => "wal_floor",
71            Self::MetadataRoot => "metadata_root",
72            Self::CheckpointRecord => "checkpoint_record",
73            Self::UploadSession => "upload_session",
74            Self::CompactionLease => "compaction_lease",
75        }
76    }
77
78    /// Parses a registered envelope discriminator, returning `None` for future families.
79    pub fn parse(value: &str) -> Option<Self> {
80        Self::ALL.into_iter().find(|kind| kind.as_str() == value)
81    }
82}
83
84/// Earliest sequence for which incremental WAL history is retained.
85///
86/// The floor advances monotonically by compare-and-swap and does not control
87/// live visibility. Missing or unverifiable floor state must retain more
88/// history. Objects below the floor are only deletion candidates; garbage
89/// collection still revalidates them before removal.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(deny_unknown_fields)]
92pub struct WalFloorState {
93    /// Namespace whose retained history this floor bounds.
94    pub namespace_id: NamespaceId,
95    /// Earliest sequence at which incremental replay remains promised.
96    pub floor_seq: ChangeSeq,
97    /// Unix-millisecond wall-clock time when the referenced manifest basis was last verified.
98    pub verified_at_ms: u64,
99    /// Unix-millisecond wall-clock time stamped by the successful floor update attempt.
100    pub updated_at_ms: u64,
101}
102
103/// Cold pointer to the best known materialized metadata root.
104///
105/// Manifest publication compare-and-swaps this object, never the WAL head,
106/// so head watchers see only commits. Updates are monotonic in
107/// `manifest_head_seq`; a same-seq replacement may reference a different
108/// manifest (pure compaction), and a lower-seq replacement no-ops. This
109/// object never defines live visibility.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(deny_unknown_fields)]
112pub struct MetadataRootState {
113    /// Namespace whose materialized file set this root selects.
114    pub namespace_id: NamespaceId,
115    /// Monotonic logical position of the selected manifest.
116    pub manifest_id: ManifestId,
117    /// Immutable candidate chosen at `manifest_id`.
118    pub manifest_object_id: ManifestObjectId,
119    /// Greatest namespace sequence represented by the selected manifest.
120    pub manifest_head_seq: ChangeSeq,
121    /// Must equal `payload_checksum` in the referenced manifest envelope.
122    pub manifest_payload_checksum: String,
123    /// Unix-millisecond wall-clock stamp for observability and GC grace policy, not ordering.
124    pub updated_at_ms: u64,
125}
126
127/// Lifecycle of a compaction lease: two states and one transition.
128///
129/// A lease is created `active` by the job that owns the prefix. Garbage
130/// collection moves it to `reaping` by compare-and-swap once it has expired,
131/// and that transition is the fence: the worker's next heartbeat
132/// compare-and-swap fails, so it can never publish, and only then may the
133/// collector treat the prefix as orphaned. `reaping` is terminal — nothing
134/// returns a lease to `active`, and the collector deletes the object once the
135/// prefix's unreferenced segments are gone.
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "snake_case")]
138pub enum MetadataCompactionLeaseStatus {
139    /// The job owns its prefix. Whether it is still running is a question
140    /// about `heartbeat_at_ms`, not about this field.
141    Active,
142    /// Terminal: garbage collection claimed the prefix, so the job that
143    /// wrote it is fenced and the objects under it are orphans.
144    Reaping,
145}
146
147impl fmt::Display for MetadataCompactionLeaseStatus {
148    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
149        formatter.write_str(match self {
150            Self::Active => "active",
151            Self::Reaping => "reaping",
152        })
153    }
154}
155
156/// Says who owns the objects under one streaming metadata compaction's job
157/// prefix.
158///
159/// The lease carries lifecycle ownership and nothing else: no cursor, no
160/// output descriptor, no offset, no progress. Two parties write it. The job
161/// creates it, refreshes it by compare-and-swap while it runs, and stops
162/// writing it once its output is published. Garbage collection claims an
163/// expired lease by compare-and-swap and reclaims the prefix behind it
164/// (format spec, "Garbage collection", rule 12). Whoever wins that
165/// compare-and-swap owns the prefix; the loser has lost it for good.
166///
167/// Nothing about a job's correctness depends on the object surviving — a job
168/// that loses its lease loses its output to a later pass and runs again,
169/// which is what every other way a job can end already costs.
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(deny_unknown_fields)]
172pub struct MetadataCompactionLeaseState {
173    /// Job this lease belongs to, which is also the prefix its output sits
174    /// under.
175    pub job_id: MetadataCompactionId,
176    /// Namespace whose family group the job is rebuilding.
177    pub namespace_id: NamespaceId,
178    /// Writer identity the job runs under, for an operator reading the
179    /// object.
180    pub owner_id: String,
181    /// Who owns the prefix: the job that wrote the lease, or the collector
182    /// that claimed it.
183    pub status: MetadataCompactionLeaseStatus,
184    /// Unix-millisecond stamp of the job's first lease write.
185    pub started_at_ms: u64,
186    /// Unix-millisecond stamp of the most recent lease write, and the only
187    /// input to whether an `active` lease has expired.
188    pub heartbeat_at_ms: u64,
189}
190
191/// Monotonic lifecycle of a durable checkpoint record.
192///
193/// A new record starts active and pins its basis. Explicit release or expiry
194/// moves it to the terminal released state by compare-and-swap. Released
195/// records serve no reads and are deleted after the release grace period.
196/// Creating another pin always creates a new record id.
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
198#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
199pub enum CheckpointRecordLifecycle {
200    /// Protects the checkpoint basis. The sole state a read may serve from.
201    ///
202    /// The braces make serde reject a stray `released_at_ms`; a unit variant
203    /// would silently accept and discard that field.
204    Active {},
205    /// Terminal: the pin is gone and the record is waiting to be deleted.
206    Released {
207        /// Unix-millisecond stamp written by the release compare-and-swap,
208        /// and the only input to when the record may be deleted.
209        released_at_ms: u64,
210    },
211}
212
213impl std::fmt::Display for CheckpointRecordLifecycle {
214    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215        let state = match self {
216            Self::Active {} => "active",
217            Self::Released { .. } => "released",
218        };
219        formatter.write_str(state)
220    }
221}
222
223/// Durable owner and expiry policy of a checkpoint record.
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
226pub enum CheckpointOwner {
227    /// An operator-created pin, released explicitly by checkpoint id or by
228    /// its declared expiry. The name is a label, not a key: several records
229    /// may carry the same name over different bases.
230    User {
231        /// Operator-facing label that need not be unique.
232        name: String,
233        /// When garbage collection may release the pin without an explicit request.
234        #[serde(default, skip_serializing_if = "Option::is_none")]
235        expires_at_ms: Option<u64>,
236    },
237    /// A fork target keeping its source basis alive. Released once the
238    /// target namespace is terminally deleted, or once the attempt's lease
239    /// expires with no target head to show for it. A live target keeps the
240    /// record whatever the lease says.
241    Fork {
242        /// Fork namespace whose continued existence keeps the source basis pinned.
243        target_namespace_id: NamespaceId,
244        /// Lease bounding the fork attempt before its target head is installed.
245        expires_at_ms: u64,
246    },
247}
248
249impl CheckpointOwner {
250    /// When garbage collection may release this record without asking its owner.
251    pub fn expires_at_ms(&self) -> Option<u64> {
252        match self {
253            Self::User { expires_at_ms, .. } => *expires_at_ms,
254            Self::Fork { expires_at_ms, .. } => Some(*expires_at_ms),
255        }
256    }
257}
258
259/// A checkpoint record: pins one metadata manifest (its basis) so garbage
260/// collection keeps everything the manifest references.
261///
262/// Stored as its own object under `checkpoints/`; never part of a manifest
263/// and never an input to latest visibility. Created write-then-verify: the
264/// record is written `active`, then the basis manifest is re-verified
265/// against the floor, and a failed verification flips the record to
266/// `released`.
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
268#[serde(deny_unknown_fields)]
269pub struct CheckpointRecordState {
270    /// Freshly generated record identity, one per logical pin. Nothing
271    /// derives it, and no caller supplies it, so a new pin can never land on
272    /// a released record's key.
273    pub checkpoint_id: CheckpointId,
274    /// Source namespace whose manifest and metadata remain pinned.
275    pub namespace_id: NamespaceId,
276    /// Logical manifest position of the pinned basis.
277    pub manifest_id: ManifestId,
278    /// Immutable manifest candidate selected at `manifest_id`.
279    pub manifest_object_id: ManifestObjectId,
280    /// Greatest source sequence materialized by the pinned manifest.
281    pub manifest_head_seq: ChangeSeq,
282    /// Must equal `payload_checksum` in the referenced manifest envelope.
283    pub manifest_payload_checksum: String,
284    /// Commit identity at the pinned manifest head, verified against its payload.
285    pub head_commit_id: CommitId,
286    /// Unix-millisecond creation stamp used by GC grace policy, never validity ordering.
287    pub created_at_ms: u64,
288    /// Party and expiry policy that determine when this pin can be released.
289    pub owner: CheckpointOwner,
290    /// Current lifecycle, advanced only by the one-way release compare-and-swap.
291    pub state: CheckpointRecordLifecycle,
292}
293
294/// Links one accepted WAL segment identity to its verified sequence range.
295///
296/// See [WAL segment rules](../../../docs/specs/format.md#15-wal-segment-rules).
297#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
298#[serde(deny_unknown_fields)]
299pub struct WalSegmentPointer {
300    /// Segment identity used to derive the immutable object key and expected
301    /// to agree with the decoded payload.
302    pub segment_id: WalSegmentId,
303    /// First logical commit sequence carried by the segment.
304    pub start_seq: ChangeSeq,
305    /// Final logical commit sequence carried by the segment.
306    pub end_seq: ChangeSeq,
307    /// Checksum of the referenced segment's payload bytes, in `sha256:<hex>`
308    /// form. Must equal the `payload_checksum` in the referenced envelope.
309    pub payload_checksum: String,
310}
311
312/// Who most recently acquired the writer epoch, and when.
313///
314/// Observability only, written during the epoch-acquisition CAS. Fencing
315/// authority is `writer_epoch` + CAS; nothing may consult this block for
316/// commit validity, takeover permission, or expiry, and no wall-clock
317/// comparison may gate a publish.
318///
319/// There is no session identity here: two runs of the same writer are told
320/// apart by `acquired_at_ms`, not by an id.
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
322#[serde(deny_unknown_fields)]
323pub struct WriterBlock {
324    /// Stable writer label supplied by the embedding process for diagnostics.
325    pub writer_id: String,
326    /// Unix-millisecond stamp of the successful epoch-acquisition CAS.
327    pub acquired_at_ms: u64,
328}
329
330/// Captures the writer identity and fencing epoch a session must retain while publishing.
331///
332/// See [mutable control-object rules](../../../docs/specs/format.md#17-mutable-control-object-rules).
333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
334pub struct AcquiredWriter {
335    /// Stable writer label copied into the head's observability block.
336    pub writer_id: String,
337    /// Fencing epoch every commit publication from this session must match.
338    pub writer_epoch: WriterEpoch,
339}
340
341/// Lifecycle state recorded in the namespace head.
342///
343/// There is no initialization state: the head is published complete by one
344/// conditional write, so a namespace either has a head (active or deleted)
345/// or does not exist. The one transition the head must record is deletion,
346/// because a deleted namespace keeps its head forever as the id-reuse
347/// tombstone.
348///
349/// Decoding is fail-closed: a reader presented with a state it does not
350/// recognize fails with a typed decode error instead of serving the
351/// namespace.
352#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(rename_all = "snake_case")]
354pub enum NamespaceState {
355    /// The namespace serves reads and accepts commits.
356    #[default]
357    Active,
358    /// Terminal: the namespace's history has ended. Reads, commits, forks,
359    /// and re-creation of the same id are all refused.
360    Deleted,
361}
362
363impl NamespaceState {
364    /// Whether this is the default state, used to keep active heads encoded
365    /// exactly as before the field existed.
366    pub fn is_active(&self) -> bool {
367        matches!(self, NamespaceState::Active)
368    }
369}
370
371/// Where a fork target's metadata basis lives before the target publishes
372/// its own manifest, and the permanent record of what it was forked from.
373///
374/// Present in every successor head of a fork target, absent in every head of
375/// a created namespace. The basis is head-authorized: a reader that resolves
376/// through it must verify the loaded manifest against both the namespace id
377/// and the checksum recorded here, and report corruption on any mismatch —
378/// there is no fallback (format spec, "Resolving the metadata basis").
379#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
380#[serde(deny_unknown_fields)]
381pub struct ForkBasis {
382    /// Namespace whose durable tree owns the basis manifest and its tables.
383    pub source_namespace_id: NamespaceId,
384    /// Immutable manifest the target starts from, under the source's prefix.
385    pub source_manifest_object_id: ManifestObjectId,
386    /// Must equal `payload_checksum` in the referenced manifest envelope.
387    pub source_manifest_checksum: String,
388    /// Source checkpoint record pinning the basis for as long as the target lives.
389    pub source_checkpoint_id: CheckpointId,
390    /// Source sequence the target's history begins at: its birth seq, and
391    /// the floor below which the target never had WAL history of its own.
392    pub fork_seq: ChangeSeq,
393}
394
395/// Carries the authoritative visibility, allocation, and fencing state of a namespace.
396///
397/// See [head update authority](../../../docs/specs/format.md#14-head-update-authority).
398#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
399#[serde(deny_unknown_fields)]
400pub struct HeadState {
401    /// Namespace whose live history this head governs.
402    pub namespace_id: NamespaceId,
403    /// Immutable content store in which the namespace publishes file bytes.
404    /// Minted at creation; a fork target carries its source's, sharing the
405    /// content keyspace copy-on-write.
406    pub content_store_id: ContentStoreId,
407    /// Time the namespace was created, in Unix milliseconds. Sequence numbers
408    /// determine order; this value is for display.
409    pub created_at_ms: u64,
410    /// Provenance and pre-first-flush basis of a fork target; absent for a
411    /// created namespace. Immutable for the namespace's life.
412    #[serde(default, skip_serializing_if = "Option::is_none")]
413    pub fork_basis: Option<ForkBasis>,
414    /// Greatest visible logical commit sequence.
415    pub seq: ChangeSeq,
416    /// Commit id assigned to `seq`, or the fixed genesis id at sequence zero.
417    pub head_commit_id: CommitId,
418    /// Current fencing generation; a publisher holding any other epoch is rejected.
419    pub writer_epoch: WriterEpoch,
420    /// Non-authoritative record of the most recent epoch acquisition.
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub writer: Option<WriterBlock>,
423    /// First namespace-scoped inode identity available for allocation.
424    pub next_inode_id: InodeId,
425    /// Accepted tip of the visible WAL chain, or `None` before the first commit.
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    pub visible_wal_tip: Option<WalSegmentPointer>,
428    /// Bounded newest-first predecessor accelerator below `visible_wal_tip`.
429    /// Chain links remain the only history authority — any disagreement
430    /// resolves in favor of the chain, and this array never protects anything
431    /// from GC.
432    #[serde(default, skip_serializing_if = "Vec::is_empty")]
433    pub recent_segments: Vec<WalSegmentPointer>,
434    /// Lifecycle state. Absent means active, on read and on write, so the
435    /// field appears only in deleted heads.
436    #[serde(default, skip_serializing_if = "NamespaceState::is_active")]
437    pub state: NamespaceState,
438}
439
440const GENESIS_COMMIT_ID: &str = "c_00000000000000000000000000000000";
441
442/// The commit id every namespace's sequence zero carries, before any commit
443/// has landed.
444pub fn genesis_commit_id() -> CommitId {
445    CommitId::parse(GENESIS_COMMIT_ID).expect("genesis commit id is valid")
446}
447
448/// A successor head changed one of the namespace's immutable identity
449/// fields. Every head a namespace ever publishes carries them forward
450/// verbatim from the head that created it.
451#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
452pub struct HeadIdentityDrift {
453    /// Which field the successor changed.
454    pub field: String,
455}
456
457impl fmt::Display for HeadIdentityDrift {
458    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
459        write!(
460            formatter,
461            "successor head changes the namespace's immutable `{}`",
462            self.field
463        )
464    }
465}
466
467impl HeadState {
468    /// Constructs the active sequence-zero head with the root inode already reserved.
469    pub fn initial(
470        namespace_id: NamespaceId,
471        content_store_id: ContentStoreId,
472        created_at_ms: u64,
473    ) -> Self {
474        Self {
475            namespace_id,
476            content_store_id,
477            created_at_ms,
478            fork_basis: None,
479            seq: ChangeSeq(0),
480            head_commit_id: CommitId::parse(GENESIS_COMMIT_ID).expect("genesis commit id is valid"),
481            writer_epoch: WriterEpoch(0),
482            writer: None,
483            // Inode 1 is the root directory; inode 2 is the first assignable id.
484            next_inode_id: crate::FIRST_ALLOCATABLE_INODE_ID,
485            visible_wal_tip: None,
486            recent_segments: Vec::new(),
487            state: NamespaceState::Active,
488        }
489    }
490
491    /// Checks that `successor` carries this head's immutable identity
492    /// forward verbatim.
493    ///
494    /// The head is the only durable home of the namespace's content store
495    /// and fork provenance, so every publication that rewrites
496    /// the head must copy them unchanged. Publishers call this before the
497    /// compare-and-swap: a drifting successor is a construction bug, not a
498    /// state to persist.
499    pub fn ensure_successor_identity(
500        &self,
501        successor: &HeadState,
502    ) -> Result<(), HeadIdentityDrift> {
503        let drift = |field: &str| {
504            Err(HeadIdentityDrift {
505                field: field.to_owned(),
506            })
507        };
508        if successor.namespace_id != self.namespace_id {
509            return drift("namespace_id");
510        }
511        if successor.content_store_id != self.content_store_id {
512            return drift("content_store_id");
513        }
514        if successor.created_at_ms != self.created_at_ms {
515            return drift("created_at_ms");
516        }
517        if successor.fork_basis != self.fork_basis {
518            return drift("fork_basis");
519        }
520        Ok(())
521    }
522}
523
524/// Staging progress for a service-proxied upload.
525#[derive(Debug, Clone, PartialEq, Eq)]
526pub enum ProxiedStaging {
527    /// No request owns the staging slot and no content has been staged.
528    Idle,
529    /// One request owns the staging slot.
530    Claimed,
531    /// Content that passed validation and was recorded by the session.
532    Staged(ContentRef),
533}
534
535impl Serialize for ProxiedStaging {
536    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
537    where
538        S: serde::Serializer,
539    {
540        #[derive(Serialize)]
541        #[serde(tag = "kind", rename_all = "snake_case")]
542        enum Shape<'a> {
543            Idle {},
544            Claimed {},
545            Staged { content_ref: &'a ContentRef },
546        }
547
548        match self {
549            Self::Idle => Shape::Idle {}.serialize(serializer),
550            Self::Claimed => Shape::Claimed {}.serialize(serializer),
551            Self::Staged(content_ref) => Shape::Staged { content_ref }.serialize(serializer),
552        }
553    }
554}
555
556impl<'de> Deserialize<'de> for ProxiedStaging {
557    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
558    where
559        D: Deserializer<'de>,
560    {
561        StrictProxiedStaging::deserialize(deserializer).map(Into::into)
562    }
563}
564
565/// The transport never changes, and each variant stores only the state
566/// required by that upload path.
567#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
568#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
569pub enum UploadSessionTransport {
570    /// The service receives the bytes and writes the content object itself,
571    /// so it learns size and digest from the bytes as they pass.
572    ServiceProxied {
573        /// Exclusive staging progress, which applies only to this transport.
574        staging: ProxiedStaging,
575    },
576    /// The client writes the whole object through one presigned request.
577    DirectPut {
578        /// The reference that signed write is minted for.
579        ///
580        /// A direct-put client declares its byte length and SHA-256 before
581        /// the write is authorized, because both are signed into the
582        /// request and the provider refuses any body that does not match.
583        /// Completion reads the stored object back against this same
584        /// reference rather than believing it.
585        promised_content: ContentRef,
586    },
587    /// The client uploads parts and the provider assembles the object.
588    ///
589    /// Multipart sessions do not store a content reference at creation because
590    /// one-pass and streaming clients may not know the final size or checksum.
591    /// The client supplies those values at completion, when LoonFS verifies the
592    /// assembled object.
593    DirectMultipart {
594        /// The provider-side upload the parts assemble through, and the
595        /// only provider handle LoonFS keeps: parts are the client's
596        /// bookkeeping, exactly as they are in the provider's own API, so
597        /// there is no durable record per part.
598        provider_upload_id: String,
599        /// Byte length of every part except the last, settled at begin.
600        ///
601        /// A session resumed after a lost begin response reads its geometry
602        /// from here rather than being told a second, possibly different,
603        /// one. Zero is not a geometry, so it is not representable.
604        part_size_bytes: NonZeroU64,
605        /// Checksum algorithm frozen when the session began. Part signing
606        /// and completion must use this decision after any process restart.
607        checksum_algorithm: ChecksumAlgorithm,
608    },
609}
610
611impl UploadSessionTransport {
612    /// The content reference this transport names, when it names one.
613    fn content_ref(&self) -> Option<&ContentRef> {
614        match self {
615            Self::DirectPut { promised_content } => Some(promised_content),
616            Self::ServiceProxied {
617                staging: ProxiedStaging::Staged(content_ref),
618            } => Some(content_ref),
619            Self::ServiceProxied { .. } | Self::DirectMultipart { .. } => None,
620        }
621    }
622}
623
624/// Monotonic lifecycle of a durable upload session.
625///
626/// A session starts open and ends either completed or aborted. The
627/// compare-and-swap that writes the terminal state decides which transition
628/// won; provider cleanup happens afterward. Terminal sessions never reopen,
629/// so another attempt requires a new session and content id.
630#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
631#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
632pub enum UploadSessionLifecycle {
633    /// The one state that may stage bytes and complete. Live until its lease
634    /// passes, after which garbage collection aborts it.
635    Open {
636        /// Unix-millisecond instant after which the session is abandoned.
637        /// The record carries it so no session transition depends on an
638        /// object's provider timestamp.
639        expires_at_ms: u64,
640    },
641    /// Terminal: the content is durable and verified. This is the only state
642    /// a receipt may be minted from, and the content reference it carries is
643    /// what every re-mint and idempotent completion retry answers with.
644    Completed {
645        /// Unix-millisecond stamp written by the completing compare-and-swap,
646        /// and the only input to when the content may be reclaimed.
647        completed_at_ms: u64,
648        /// Verified immutable content this session settled on, and the one
649        /// place a completed session's reference exists.
650        content_ref: ContentRef,
651    },
652    /// Terminal: the session will never select content. Its content
653    /// identity was never published — a receipt exists only for a completed
654    /// session — so the object it named belongs to nobody and is deleted.
655    Aborted {
656        /// Unix-millisecond stamp written by the aborting compare-and-swap,
657        /// and the only input to when the record may be deleted.
658        aborted_at_ms: u64,
659    },
660}
661
662impl UploadSessionLifecycle {
663    /// The content reference this state names, whichever state names one.
664    fn content_ref(&self) -> Option<&ContentRef> {
665        match self {
666            Self::Open { .. } => None,
667            Self::Completed { content_ref, .. } => Some(content_ref),
668            Self::Aborted { .. } => None,
669        }
670    }
671}
672
673impl std::fmt::Display for UploadSessionLifecycle {
674    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
675        let state = match self {
676            Self::Open { .. } => "open",
677            Self::Completed { .. } => "completed",
678            Self::Aborted { .. } => "aborted",
679        };
680        formatter.write_str(state)
681    }
682}
683
684/// Tracks one durable content-upload workflow independently of commit publication.
685///
686/// The record is an identity, a transport, and a state. Everything a
687/// transport needs lives in its own variant and everything a state needs
688/// lives in its own variant, so the combinations that used to be spelled
689/// with independent optional fields — a proxied session holding a provider
690/// upload or a multipart session promising content — are not shapes this
691/// type has.
692///
693/// See [upload before publish](../../../docs/specs/format.md#242-upload-before-publish).
694#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
695pub struct UploadSessionState {
696    /// Namespace authorized to consume the staged content.
697    pub namespace_id: NamespaceId,
698    /// Durable session identity used by staging and completion requests.
699    pub upload_id: UploadId,
700    /// Content object this session writes, allocated when the session began.
701    ///
702    /// The identity exists before any byte is read, so the final object key
703    /// is known up front and belongs to exactly this session. Every
704    /// reference the record holds names this object; see `validate` below,
705    /// which refuses a record that disagrees with itself.
706    pub content_id: ContentId,
707    /// Unix-millisecond creation stamp.
708    pub created_at_ms: u64,
709    /// How the bytes reach object storage, settled when the session opened.
710    pub transport: UploadSessionTransport,
711    /// The session's lifecycle, and the field every upload operation
712    /// compare-and-swaps against.
713    pub state: UploadSessionLifecycle,
714}
715
716impl UploadSessionState {
717    /// Proves the relationships this record's shape cannot express but every
718    /// reader of one depends on.
719    ///
720    /// Every reference the record holds is about the same content object,
721    /// whose identity the session allocated before any byte moved. A record
722    /// whose references disagree with its own `content_id` describes two
723    /// objects and cannot be acted on — a completion would verify one key
724    /// and publish another.
725    ///
726    /// A direct-put session must also settle on exactly the reference its
727    /// write was signed against, because that reference is what the provider
728    /// enforced and what completion read back. A record that says otherwise
729    /// describes an upload that did not happen.
730    fn validate(&self) -> Result<(), String> {
731        for content_ref in self
732            .transport
733            .content_ref()
734            .into_iter()
735            .chain(self.state.content_ref())
736        {
737            content_ref.validate().map_err(|error| {
738                format!(
739                    "upload session `{}` holds an invalid content ref: {error}",
740                    self.upload_id
741                )
742            })?;
743            if content_ref.content_id != self.content_id {
744                return Err(format!(
745                    "upload session `{}` owns content `{}` but holds a reference to `{}`",
746                    self.upload_id, self.content_id, content_ref.content_id
747                ));
748            }
749        }
750        match (&self.transport, &self.state) {
751            (
752                UploadSessionTransport::DirectPut { promised_content },
753                UploadSessionLifecycle::Completed { content_ref, .. },
754            ) if content_ref != promised_content => Err(format!(
755                "upload session `{}` completed on content its direct write never promised",
756                self.upload_id
757            )),
758            _ => Ok(()),
759        }
760    }
761}
762
763#[derive(Deserialize)]
764#[serde(deny_unknown_fields)]
765struct StrictUploadSessionState {
766    namespace_id: NamespaceId,
767    upload_id: UploadId,
768    content_id: ContentId,
769    created_at_ms: u64,
770    transport: StrictUploadSessionTransport,
771    state: StrictUploadSessionLifecycle,
772}
773
774/// The transport read back through the same strict content-ref decoder the
775/// rest of the record uses.
776#[derive(Deserialize)]
777#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
778enum StrictUploadSessionTransport {
779    ServiceProxied {
780        staging: StrictProxiedStaging,
781    },
782    DirectPut {
783        promised_content: StrictContentRef,
784    },
785    DirectMultipart {
786        provider_upload_id: String,
787        part_size_bytes: NonZeroU64,
788        checksum_algorithm: ChecksumAlgorithm,
789    },
790}
791
792impl From<StrictUploadSessionTransport> for UploadSessionTransport {
793    fn from(transport: StrictUploadSessionTransport) -> Self {
794        match transport {
795            StrictUploadSessionTransport::ServiceProxied { staging } => Self::ServiceProxied {
796                staging: staging.into(),
797            },
798            StrictUploadSessionTransport::DirectPut { promised_content } => Self::DirectPut {
799                promised_content: promised_content.into(),
800            },
801            StrictUploadSessionTransport::DirectMultipart {
802                provider_upload_id,
803                part_size_bytes,
804                checksum_algorithm,
805            } => Self::DirectMultipart {
806                provider_upload_id,
807                part_size_bytes,
808                checksum_algorithm,
809            },
810        }
811    }
812}
813
814#[derive(Deserialize)]
815#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
816enum StrictProxiedStaging {
817    Idle {},
818    Claimed {},
819    Staged { content_ref: StrictContentRef },
820}
821
822impl From<StrictProxiedStaging> for ProxiedStaging {
823    fn from(staging: StrictProxiedStaging) -> Self {
824        match staging {
825            StrictProxiedStaging::Idle {} => Self::Idle,
826            StrictProxiedStaging::Claimed {} => Self::Claimed,
827            StrictProxiedStaging::Staged { content_ref } => Self::Staged(content_ref.into()),
828        }
829    }
830}
831
832/// The lifecycle read back through the same strict content-ref decoder the
833/// rest of the record uses, so a completed session's reference is held to
834/// the durable schema rather than the wire one.
835#[derive(Deserialize)]
836#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
837enum StrictUploadSessionLifecycle {
838    Open {
839        expires_at_ms: u64,
840    },
841    Completed {
842        completed_at_ms: u64,
843        content_ref: StrictContentRef,
844    },
845    Aborted {
846        aborted_at_ms: u64,
847    },
848}
849
850impl From<StrictUploadSessionLifecycle> for UploadSessionLifecycle {
851    fn from(state: StrictUploadSessionLifecycle) -> Self {
852        match state {
853            StrictUploadSessionLifecycle::Open { expires_at_ms } => Self::Open { expires_at_ms },
854            StrictUploadSessionLifecycle::Completed {
855                completed_at_ms,
856                content_ref,
857            } => Self::Completed {
858                completed_at_ms,
859                content_ref: content_ref.into(),
860            },
861            StrictUploadSessionLifecycle::Aborted { aborted_at_ms } => {
862                Self::Aborted { aborted_at_ms }
863            }
864        }
865    }
866}
867
868#[derive(Deserialize)]
869#[serde(deny_unknown_fields)]
870struct StrictContentRef {
871    kind: MutableContentRefKind,
872    content_id: ContentId,
873    size_bytes: u64,
874    checksum: Checksum,
875}
876
877#[derive(Deserialize)]
878#[serde(rename_all = "snake_case")]
879enum MutableContentRefKind {
880    BlobV1,
881}
882
883impl From<StrictContentRef> for ContentRef {
884    fn from(content_ref: StrictContentRef) -> Self {
885        let kind = match content_ref.kind {
886            MutableContentRefKind::BlobV1 => ContentRefKind::BlobV1,
887        };
888        Self {
889            kind,
890            content_id: content_ref.content_id,
891            size_bytes: content_ref.size_bytes,
892            checksum: content_ref.checksum,
893        }
894    }
895}
896
897impl<'de> Deserialize<'de> for UploadSessionState {
898    /// Reads one session record and refuses one that `validate` finds
899    /// disagreeing with itself, like any other corruption and with no shim
900    /// or salvage.
901    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
902    where
903        D: Deserializer<'de>,
904    {
905        let state = StrictUploadSessionState::deserialize(deserializer)?;
906        let session = Self {
907            namespace_id: state.namespace_id,
908            upload_id: state.upload_id,
909            content_id: state.content_id,
910            created_at_ms: state.created_at_ms,
911            transport: state.transport.into(),
912            state: state.state.into(),
913        };
914        session.validate().map_err(serde::de::Error::custom)?;
915        Ok(session)
916    }
917}
918
919/// In-memory view of a control object envelope.
920///
921/// This struct is not the durable layout; durable bytes are produced only by
922/// [`encode_control_object`] and validated only by [`decode_control_object`].
923#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
924pub struct ControlObjectEnvelope<T> {
925    /// Durable-family discriminator that selects `T` and its independent version.
926    pub kind: ControlObjectKind,
927    /// Family-local format version obtained from `kind`.
928    pub format_version: u32,
929    /// Digest of the payload JSON exactly as stored in the durable document,
930    /// in `sha256:<hex>` form.
931    pub payload_checksum: String,
932    /// Decoded control state protected by `payload_checksum`.
933    pub state: T,
934}
935
936impl<T> ControlObjectEnvelope<T>
937where
938    T: Serialize,
939{
940    /// Builds a family-versioned envelope and computes its checksum from canonical state JSON.
941    ///
942    /// Construction fails when `state` cannot be encoded.
943    pub fn from_state(kind: ControlObjectKind, state: T) -> Result<Self, EnvelopeCodecError> {
944        Ok(Self {
945            kind,
946            format_version: kind.format_version(),
947            payload_checksum: control_payload_checksum(&state)?,
948            state,
949        })
950    }
951}
952
953/// Specializes a control envelope for the authoritative namespace head.
954pub type HeadStateEnvelope = ControlObjectEnvelope<HeadState>;
955/// Specializes a control envelope for a durable upload workflow.
956pub type UploadSessionEnvelope = ControlObjectEnvelope<UploadSessionState>;
957/// Specializes a control envelope for the selected materialized manifest.
958pub type MetadataRootEnvelope = ControlObjectEnvelope<MetadataRootState>;
959/// Specializes a control envelope for the retained-history floor.
960pub type WalFloorEnvelope = ControlObjectEnvelope<WalFloorState>;
961/// Specializes a control envelope for a durable manifest pin.
962pub type CheckpointRecordEnvelope = ControlObjectEnvelope<CheckpointRecordState>;
963/// Specializes a control envelope for a running compaction's ownership of
964/// its staged output.
965pub type MetadataCompactionLeaseEnvelope = ControlObjectEnvelope<MetadataCompactionLeaseState>;
966
967/// Computes the checksum stored beside canonical JSON for a control state.
968///
969/// Computation fails when `state` cannot be serialized.
970pub fn control_payload_checksum<T>(state: &T) -> Result<String, EnvelopeCodecError>
971where
972    T: Serialize,
973{
974    crate::envelope::json_payload_checksum(state)
975}
976
977/// Encodes a control-object envelope as its durable JSON representation.
978///
979/// Encoding fails when the family version is unsupported, the in-memory
980/// checksum is stale, or JSON serialization fails. See
981/// [mutable control-object rules](../../../docs/specs/format.md#17-mutable-control-object-rules).
982pub fn encode_control_object<T>(
983    envelope: &ControlObjectEnvelope<T>,
984) -> Result<Vec<u8>, EnvelopeCodecError>
985where
986    T: Serialize,
987{
988    crate::envelope::encode_json_envelope(
989        envelope.kind.as_str(),
990        envelope.format_version,
991        envelope.kind.format_version(),
992        &envelope.payload_checksum,
993        &envelope.state,
994    )
995}
996
997/// Encodes state in a control-object envelope.
998pub fn encode_control_state<T: Serialize>(
999    kind: ControlObjectKind,
1000    state: &T,
1001) -> Result<Vec<u8>, EnvelopeCodecError> {
1002    let envelope = ControlObjectEnvelope::from_state(kind, state)?;
1003    encode_control_object(&envelope)
1004}
1005
1006/// Decodes and verifies a durable JSON control object of `expected_kind`.
1007///
1008/// Decoding fails for invalid JSON, an unknown or mismatched kind, an
1009/// unsupported family version, a checksum mismatch, or an invalid `T`. See
1010/// [mutable control-object rules](../../../docs/specs/format.md#17-mutable-control-object-rules).
1011pub fn decode_control_object<T>(
1012    bytes: &[u8],
1013    expected_kind: ControlObjectKind,
1014) -> Result<ControlObjectEnvelope<T>, EnvelopeCodecError>
1015where
1016    T: DeserializeOwned,
1017{
1018    let decoded = crate::envelope::decode_strict_json_envelope(
1019        bytes,
1020        expected_kind.format_version(),
1021        // The kind registry reports unknown kinds distinctly from
1022        // registered-but-mismatched ones.
1023        |found| match ControlObjectKind::parse(found) {
1024            None => Err(EnvelopeCodecError::UnknownKind {
1025                found: found.to_owned(),
1026            }),
1027            Some(kind) if kind != expected_kind => Err(EnvelopeCodecError::KindMismatch {
1028                expected: expected_kind.as_str().to_owned(),
1029                found: found.to_owned(),
1030            }),
1031            Some(_) => Ok(()),
1032        },
1033    )?;
1034
1035    Ok(ControlObjectEnvelope {
1036        kind: expected_kind,
1037        format_version: decoded.format_version,
1038        payload_checksum: decoded.payload_checksum,
1039        state: decoded.payload,
1040    })
1041}
1042
1043#[cfg(test)]
1044mod tests {
1045    use super::*;
1046
1047    #[test]
1048    fn control_object_kind_strings_round_trip_and_match_serde() {
1049        for kind in ControlObjectKind::ALL {
1050            assert_eq!(ControlObjectKind::parse(kind.as_str()), Some(kind));
1051            let serialized = serde_json::to_value(kind).expect("serialize kind");
1052            assert_eq!(serialized, serde_json::Value::from(kind.as_str()));
1053        }
1054        assert_eq!(ControlObjectKind::parse("not_a_kind"), None);
1055    }
1056
1057    fn sample_head() -> HeadState {
1058        HeadState::initial(
1059            NamespaceId::parse("demo").expect("valid namespace id"),
1060            ContentStoreId::parse("cs_0123456789abcdef0123456789abcdef")
1061                .expect("valid content store id"),
1062            1_000,
1063        )
1064    }
1065
1066    #[test]
1067    fn head_without_content_store_is_rejected() {
1068        // The content store is the namespace's addressing-semantics
1069        // authority; a head that omits it is malformed, never defaulted.
1070        let missing = serde_json::json!({
1071            "namespace_id": "demo",
1072            "seq": 0,
1073            "head_commit_id": GENESIS_COMMIT_ID,
1074            "writer_epoch": 0,
1075            "next_inode_id": 2
1076        });
1077
1078        serde_json::from_value::<HeadState>(missing)
1079            .expect_err("head without its immutable identity must be rejected");
1080    }
1081
1082    /// One WAL pointer as it appears inside a durable head payload.
1083    fn wal_pointer_json(segment_id: &str, start_seq: u64, end_seq: u64) -> serde_json::Value {
1084        serde_json::json!({
1085            "segment_id": segment_id,
1086            "start_seq": start_seq,
1087            "end_seq": end_seq,
1088            "payload_checksum": format!("sha256:{}", "b".repeat(64)),
1089        })
1090    }
1091
1092    /// A decodable head payload carrying whatever tip and accelerator the
1093    /// caller wants to present. Both fields are omitted when empty, exactly
1094    /// as the encoder writes them.
1095    fn head_json(
1096        visible_wal_tip: Option<serde_json::Value>,
1097        recent_segments: Vec<serde_json::Value>,
1098    ) -> serde_json::Value {
1099        let mut head = serde_json::json!({
1100            "namespace_id": "demo",
1101            "content_store_id": "cs_0123456789abcdef0123456789abcdef",
1102            "created_at_ms": 1_000,
1103            "seq": 2,
1104            "head_commit_id": GENESIS_COMMIT_ID,
1105            "writer_epoch": 0,
1106            "next_inode_id": 2
1107        });
1108        if let Some(tip) = visible_wal_tip {
1109            head["visible_wal_tip"] = tip;
1110        }
1111        if !recent_segments.is_empty() {
1112            head["recent_segments"] = serde_json::Value::Array(recent_segments);
1113        }
1114        head
1115    }
1116
1117    #[test]
1118    fn head_decodes_with_a_tip_and_no_predecessor_hints() {
1119        let tip = wal_pointer_json("00000000000000000002-fedcba9876543210", 2, 2);
1120
1121        let head = serde_json::from_value::<HeadState>(head_json(Some(tip), Vec::new()))
1122            .expect("the first published segment has no predecessor hints");
1123        assert!(head.visible_wal_tip.is_some());
1124        assert!(head.recent_segments.is_empty());
1125    }
1126
1127    #[test]
1128    fn predecessor_hints_do_not_repeat_the_tip() {
1129        let tip = wal_pointer_json("00000000000000000002-fedcba9876543210", 2, 2);
1130        let older = wal_pointer_json("00000000000000000001-0123456789abcdef", 1, 1);
1131
1132        let head = serde_json::from_value::<HeadState>(head_json(Some(tip), vec![older.clone()]))
1133            .expect("predecessor hints decode independently of the authoritative tip");
1134        assert_eq!(
1135            head.recent_segments,
1136            vec![serde_json::from_value(older).expect("valid predecessor pointer")]
1137        );
1138    }
1139
1140    #[test]
1141    fn old_pointer_object_key_is_rejected() {
1142        let mut tip = wal_pointer_json("00000000000000000002-fedcba9876543210", 2, 2);
1143        tip["object_key"] = serde_json::json!(
1144            "namespaces/demo/wal/segments/00000000000000000002-fedcba9876543210.wal.zst"
1145        );
1146
1147        serde_json::from_value::<HeadState>(head_json(Some(tip), Vec::new()))
1148            .expect_err("the v1 hard cutover rejects the former stored object key");
1149    }
1150
1151    #[test]
1152    fn genesis_head_decodes_without_a_tip_or_hints() {
1153        let genesis = serde_json::from_value::<HeadState>(head_json(None, Vec::new()))
1154            .expect("a head with no visible tip decodes");
1155        assert_eq!(genesis.visible_wal_tip, None);
1156        assert!(genesis.recent_segments.is_empty());
1157    }
1158
1159    #[test]
1160    fn control_object_codec_round_trips_and_validates() {
1161        let envelope = HeadStateEnvelope::from_state(ControlObjectKind::WalHead, sample_head())
1162            .expect("envelope");
1163
1164        let encoded = encode_control_object(&envelope).expect("encode");
1165        let decoded: HeadStateEnvelope =
1166            decode_control_object(&encoded, ControlObjectKind::WalHead).expect("decode");
1167        assert_eq!(decoded, envelope);
1168
1169        let mismatch =
1170            decode_control_object::<MetadataRootState>(&encoded, ControlObjectKind::MetadataRoot)
1171                .expect_err("kind mismatch");
1172        assert!(matches!(mismatch, EnvelopeCodecError::KindMismatch { .. }));
1173    }
1174
1175    #[test]
1176    fn successor_head_must_carry_the_namespace_identity_forward() {
1177        let head = sample_head();
1178        let mut successor = head.clone();
1179        successor.seq = ChangeSeq(4);
1180        head.ensure_successor_identity(&successor)
1181            .expect("advancing the sequence keeps the identity");
1182
1183        let mut drifted = head.clone();
1184        drifted.content_store_id = ContentStoreId::parse("cs_fedcba9876543210fedcba9876543210")
1185            .expect("valid content store id");
1186        assert_eq!(
1187            head.ensure_successor_identity(&drifted)
1188                .expect_err("content store drift is rejected")
1189                .field,
1190            "content_store_id"
1191        );
1192
1193        let mut forked = head.clone();
1194        forked.fork_basis = Some(ForkBasis {
1195            source_namespace_id: NamespaceId::parse("source").expect("valid namespace id"),
1196            source_manifest_object_id: ManifestObjectId::parse(
1197                "00000000000000000007-0123456789abcdef",
1198            )
1199            .expect("valid manifest object id"),
1200            source_manifest_checksum: "sha256:test".to_owned(),
1201            source_checkpoint_id: CheckpointId::parse("chk_00000000000000000000000000000002")
1202                .expect("valid checkpoint id"),
1203            fork_seq: ChangeSeq(7),
1204        });
1205        assert_eq!(
1206            head.ensure_successor_identity(&forked)
1207                .expect_err("gaining a fork basis is rejected")
1208                .field,
1209            "fork_basis"
1210        );
1211    }
1212}