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