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}