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