publication.rs
This Kit feature is not available yet.
In rust/crates/mkit-server/src/store/ · At 26cf72cCopy the commit ID
Preview
Code · 25.2 KiB//! `RefShard` authority for paired publication and retained inspection obligations.
//!
//! Sequence, value, membership and relay work join the caller's guarded apply.
//! Ref/`RepoIndex` projections are witnesses, never evidence of live membership.
use std::collections::BTreeSet;
use mkit_core::hash::Hash;
use mkit_primitives::decision::{
ClearanceAdvance, ClearanceDecision, ClearanceFacts, ClearanceLifecycle, ClearanceLimits,
ClearanceMoment, ClearanceState, GuardRow, Guarded, OperationId, PriorPublication,
RequiredGuard, decide_clearance,
};
use mkit_primitives::evidence::{RepositoryId, Revision};
use serde::{Deserialize, Serialize};
use super::outbox::{OutboxBuilder, guard};
use super::{
BlobKey, Key, NamespaceStore, Partition, Precondition, StoreError, Value, Write, codec, keys,
};
use crate::pipeline::ShardMap;
use crate::repo::{RepoId, RepoName};
/// Bound per-ref outstanding values and the one-round prefix read.
pub const MAX_UNPUBLISHED_ADVANCES: u64 = 64;
/// Maximum durable dependency/obligation entries per advance.
pub const MAX_ADVANCE_ITEMS: usize = 4096;
/// Durable blocked advances retry without client traffic every five seconds.
pub const RECHECK_MS: u64 = 5_000;
/// The branch head and packmap, or one non-branch target.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Pair {
/// Head, or the other ref's target.
pub head: Option<Hash>,
/// Packmap of a branch; absent for other refs.
pub packmap: Option<Hash>,
}
/// The clearance states from SPEC-SERVER §10.2.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Clearance {
/// Inspection or membership remains outstanding.
Pending,
/// All obligations and dependencies permit publication.
Cleared,
/// Quarantine requires release or re-inspection.
Held,
/// Rejection requires takedown completion.
Hit,
/// Takedown and remaining obligations are complete.
Resolved,
}
impl Clearance {
/// Whether this state permits membership and prefix publication.
#[must_use]
pub fn publishable(self) -> bool {
matches!(self, Self::Cleared | Self::Resolved)
}
}
/// Stable inspection identity and its independently retained result.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Obligation {
/// Stable identity; superseding an inspection uses a different id.
pub id: Hash,
/// The inspection result, independently of membership dependencies.
pub state: Clearance,
}
/// One retained advance; this is also the inspector's durable handoff.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Advance {
/// Never-reused sequence number.
pub sequence: u64,
/// Repository membership incarnation, supplied by lifecycle enforcement.
pub generation: u64,
/// Resulting paired live values.
pub value: Pair,
/// Packs added by this advance, including MKPL nodes.
pub additions: Vec<Hash>,
/// Packmap/closure dependencies; own additions may satisfy these.
pub dependencies: Vec<Hash>,
/// External delta source packs; own additions never satisfy these.
pub external_bases: Vec<Hash>,
/// Individually retained inspector obligations.
pub obligations: Vec<Obligation>,
/// Aggregate clearance, including dependencies and flags.
pub state: Clearance,
/// Logical operation correlation; never a credential.
pub operation: Hash,
}
impl Advance {
fn validate(&self) -> Result<(), StoreError> {
if self.sequence == 0
|| self.additions.len() > super::outbox::MAX_TICKETS_PER_ADVANCE
|| self.dependencies.len() > MAX_ADVANCE_ITEMS
|| self.external_bases.len() > MAX_ADVANCE_ITEMS
|| self.obligations.len() > MAX_ADVANCE_ITEMS
|| !unique(&self.additions)
|| !unique(&self.dependencies)
|| !unique(&self.external_bases)
{
return Err(StoreError::Invalid("invalid publication advance".into()));
}
let ids: BTreeSet<_> = self.obligations.iter().map(|o| o.id).collect();
if ids.len() != self.obligations.len()
|| self.state.publishable() && self.obligations.iter().any(|o| !o.state.publishable())
{
return Err(StoreError::Invalid(
"invalid publication obligations".into(),
));
}
Ok(())
}
/// Encode a bounded v1 retained value.
pub fn encode(&self) -> Result<Value, StoreError> {
self.validate()?;
encode(self)
}
/// Decode and validate the entire record before using any field.
pub fn decode(value: &Value) -> Result<Self, StoreError> {
let row: Self = decode(value)?;
row.validate()
.map_err(|e| StoreError::Corrupt(e.to_string().into()))?;
Ok(row)
}
}
/// Persistent sequence, contiguous pointer and deletion boundary.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Publication {
/// Last successfully applied advance; never deleted or reset.
pub sequence: u64,
/// Greatest cleared-or-resolved prefix after the deletion boundary.
pub published: u64,
/// Older verdicts cannot change ref values at or below this boundary.
pub boundary: u64,
/// Membership incarnation; lifecycle invalidation increments this.
pub generation: u64,
/// Paired published values, absent at deletion.
pub value: Pair,
}
impl Publication {
/// Decode persistent state; absence is the only initial state.
pub fn decode(value: Option<&Value>) -> Result<Self, StoreError> {
let row: Self = value.map(decode).transpose()?.unwrap_or_default();
if row.boundary > row.published || row.published > row.sequence {
return Err(StoreError::Corrupt("invalid publication prefix".into()));
}
Ok(row)
}
/// Encode persistent state.
pub fn encode(&self) -> Result<Value, StoreError> {
let value = encode(self)?;
Self::decode(Some(&value))?;
Ok(value)
}
}
/// A versioned local membership or published `RepoIndex` witness.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Witness {
/// Membership generation, invalidated by repository deletion.
pub generation: u64,
/// Advance that established clearance; zero is immediate unticketed membership.
pub sequence: u64,
/// Independently cleared membership, even when the ref prefix is behind.
pub published: bool,
/// Serving stop; applies to writers too.
pub held: bool,
/// The packmap node a completed fork inherited: it names a cleared set
/// that publication verification may skip (SPEC-SERVER §9.9).
pub boundary: bool,
}
impl Witness {
/// Fixed-width versioned encoding; no unchecked optional fields.
#[must_use]
pub fn encode(self) -> Value {
let mut bytes = vec![1, u8::from(self.published), u8::from(self.held)];
bytes.extend_from_slice(&self.generation.to_be_bytes());
bytes.extend_from_slice(&self.sequence.to_be_bytes());
if self.boundary {
bytes.push(1);
}
Value::new(bytes)
}
/// Decode a witness. Empty membership is the explicitly immediate upload form.
pub fn decode(value: &Value) -> Result<Self, StoreError> {
let bytes = value.as_bytes();
if bytes.is_empty() {
return Ok(Self {
generation: 0,
sequence: 0,
published: true,
held: false,
boundary: false,
});
}
let boundary = match bytes.len() {
19 => false,
20 if bytes[19] == 1 => true,
_ => return Err(StoreError::Corrupt("invalid clearance witness".into())),
};
if bytes[0] != 1 || bytes[1] > 1 || bytes[2] > 1 {
return Err(StoreError::Corrupt("invalid clearance witness".into()));
}
let number = |offset| -> Result<u64, StoreError> {
bytes
.get(offset..offset + 8)
.and_then(|b| b.try_into().ok())
.map(u64::from_be_bytes)
.ok_or_else(|| StoreError::Corrupt("short clearance witness".into()))
};
Ok(Self {
generation: number(3)?,
sequence: number(11)?,
published: bytes[1] != 0,
held: bytes[2] != 0,
boundary,
})
}
/// Whether the caller may use this membership in its chosen view.
#[must_use]
pub fn visible(self, writer: bool, generation: u64) -> bool {
!self.held && self.generation == generation && (writer || self.published)
}
}
fn unique(ids: &[Hash]) -> bool {
ids.iter().collect::<BTreeSet<_>>().len() == ids.len()
}
fn encode<T: Serialize>(row: &T) -> Result<Value, StoreError> {
let mut bytes = vec![1];
bytes.extend(
serde_json::to_vec(row).map_err(|_| StoreError::Invalid("publication encoding".into()))?,
);
if bytes.len() > super::MAX_VALUE_BYTES {
return Err(StoreError::Invalid(
"publication value exceeds limit".into(),
));
}
Ok(Value::new(bytes))
}
fn decode<T: serde::de::DeserializeOwned>(value: &Value) -> Result<T, StoreError> {
let bytes = value.as_bytes();
if bytes.first() != Some(&1) || bytes.len() > super::MAX_VALUE_BYTES {
return Err(StoreError::Corrupt(
"invalid publication version or size".into(),
));
}
serde_json::from_slice(&bytes[1..])
.map_err(|_| StoreError::Corrupt("invalid publication record".into()))
}
/// Canonical shared sequence name for a branch pair, unchanged for another ref.
#[must_use]
pub fn sequence_ref(name: &str) -> String {
mkit_attest::grant::packmap_head(name).unwrap_or_else(|| name.to_owned())
}
/// Resulting branch pair or standalone ref names in stable order.
#[must_use]
pub fn value_refs(name: &str, value: &Pair) -> Vec<(String, Option<Hash>)> {
let mut refs = vec![(name.to_owned(), value.head)];
if let Some(packmap) = mkit_attest::grant::head_packmap(name) {
refs.push((packmap, value.packmap));
}
refs
}
/// Append one successful ref write. The caller commits this fragment with live refs.
/// A deletion is an immediate boundary; older retained obligations remain intact.
#[allow(clippy::too_many_arguments)]
pub fn append(
repo: &RepoId,
name: &str,
source: &Partition,
shards: &dyn ShardMap,
prior: Option<&Value>,
mut advance: Advance,
deleted: bool,
pre: &mut Vec<Precondition>,
writes: &mut Vec<Write>,
outbox: &mut OutboxBuilder,
) -> Result<Publication, StoreError> {
let name = sequence_ref(name);
let mut state = Publication::decode(prior)?;
if !deleted && state.sequence - state.published >= MAX_UNPUBLISHED_ADVANCES {
return Err(StoreError::unavailable("publication backlog full"));
}
state.sequence = state
.sequence
.checked_add(1)
.ok_or_else(|| StoreError::Corrupt("advance sequence overflow".into()))?;
advance.sequence = state.sequence;
advance.generation = state.generation;
advance.validate()?;
if !advance.state.publishable() {
// One timer per advance, installed in the same transaction as its retained
// obligations. Periodic rechecks cover cross-ref publication and delayed
// projections without an unbounded reverse dependency fanout.
writes.push(Write::Put(
keys::timer(
0,
crate::timers::registry::kinds::PUBLICATION_RECHECK.get(),
keys::advance(&repo.name, &name, advance.sequence).as_bytes(),
),
crate::timers::publication_recheck::initial_value(),
));
}
let key = keys::publication(&repo.name, &name);
pre.push(guard(key.clone(), prior));
if deleted {
state.boundary = state.sequence;
state.published = state.sequence;
// A partial deletion removes its component immediately, but must not
// publish the surviving live component from an uncleared advance.
state.value = Pair {
head: advance.value.head.and(state.value.head),
packmap: advance.value.packmap.and(state.value.packmap),
};
project_refs(repo, &name, source, shards, &state.value, writes, outbox);
} else if advance.state.publishable() && state.published + 1 == state.sequence {
state.published = state.sequence;
state.value = advance.value.clone();
project_refs(repo, &name, source, shards, &state.value, writes, outbox);
}
// Completed obligation-free values need no retained work once the prefix
// includes them. Keep blocked intermediates and every inspection obligation.
if advance.sequence > state.published || !advance.obligations.is_empty() {
writes.push(Write::Put(
keys::advance(&repo.name, &name, advance.sequence),
advance.encode()?,
));
}
project_members(repo, source, shards, &advance, writes, outbox);
writes.push(Write::Put(key, state.encode()?));
Ok(state)
}
fn project_refs(
repo: &RepoId,
name: &str,
source: &Partition,
shards: &dyn ShardMap,
value: &Pair,
writes: &mut Vec<Write>,
outbox: &mut OutboxBuilder,
) {
for (name, id) in value_refs(name, value) {
let key = keys::published_ref(&repo.name, &name);
writes.push(id.map_or_else(
|| Write::Delete(key.clone()),
|id| Write::Put(key.clone(), codec::encode_ref_id(&id)),
));
let target = shards.ref_index(repo, &name);
if target != *source {
let key = keys::published_index(&repo.name, &name);
match id {
Some(id) => outbox.relay(&target, vec![(key, codec::encode_ref_id(&id))]),
None => outbox.relay_delete(&target, vec![key]),
}
}
}
}
fn project_members(
repo: &RepoId,
source: &Partition,
shards: &dyn ShardMap,
advance: &Advance,
writes: &mut Vec<Write>,
outbox: &mut OutboxBuilder,
) {
for pack in &advance.additions {
let witness = Witness {
generation: advance.generation,
sequence: advance.sequence,
published: advance.state.publishable(),
held: matches!(advance.state, Clearance::Held | Clearance::Hit),
boundary: false,
};
let key = keys::membership(&repo.name, pack);
// Replace the live membership fragment rather than spending another per-ticket put.
writes.retain(|w| !matches!(w, Write::Put(k, _) | Write::Delete(k) | Write::Increment(k) if k == &key));
writes.push(Write::Put(key.clone(), witness.encode()));
let target = shards.membership(repo, &BlobKey::pack(*pack));
if target != *source {
outbox.relay(&target, vec![(key, witness.encode())]);
if witness.published {
outbox.relay(
&target,
vec![(keys::published_member(&repo.name, pack), witness.encode())],
);
}
}
}
}
/// Read authoritative sequence state without consulting a projection.
pub async fn read<S: NamespaceStore>(
store: &S,
source: &Partition,
repo: &RepoName,
name: &str,
) -> Result<Publication, StoreError> {
Publication::decode(
store
.get(source, &keys::publication(repo, &sequence_ref(name)))
.await?
.as_ref(),
)
}
/// Bounded read of every value that can advance the pointer. No projection is used.
///
/// `state_raw` is the stored publication row `state` was decoded from: the
/// clearance decision's prior revision is that row's content digest, and the
/// caller's apply compares the same row (see [`clear`]).
pub async fn prefix<S: NamespaceStore>(
store: &S,
source: &Partition,
repo: &RepoName,
name: &str,
state_raw: &Value,
state: &Publication,
changed: &Advance,
) -> Result<(u64, Pair), StoreError> {
if state.sequence - state.published > MAX_UNPUBLISHED_ADVANCES {
return Err(StoreError::Corrupt(
"publication prefix exceeds bound".into(),
));
}
if state.published == state.sequence {
return Ok((state.published, state.value.clone()));
}
let name = sequence_ref(name);
let wanted: Vec<Key> = (state.published + 1..=state.sequence)
.map(|sequence| keys::advance(repo, &name, sequence))
.collect();
let rows = store.get_many(source, &wanted).await?;
if rows.len() != wanted.len() {
return Err(StoreError::MalformedReply(
"short publication prefix read".into(),
));
}
let mut decoded = Vec::with_capacity(wanted.len());
let mut revisions = Vec::with_capacity(wanted.len());
for (sequence, raw) in (state.published + 1..=state.sequence).zip(rows) {
revisions.push(row_revision(raw.as_ref()));
let current = if changed.sequence == sequence {
changed.clone()
} else {
Advance::decode(
raw.as_ref()
.ok_or_else(|| StoreError::Corrupt("missing retained advance".into()))?,
)?
};
if current.sequence != sequence || current.generation != state.generation {
return Err(StoreError::Corrupt(
"retained advance binding mismatch".into(),
));
}
decoded.push(current);
}
if !decoded.iter().any(|row| row.sequence == changed.sequence) {
return Ok(stored_prefix(state, &decoded));
}
clearance_prefix(
row_revision(Some(state_raw)),
state,
changed,
&decoded,
&revisions,
)
}
fn stored_prefix(state: &Publication, decoded: &[Advance]) -> (u64, Pair) {
let mut result = (state.published, state.value.clone());
for current in decoded {
if !current.state.publishable() {
break;
}
result = (current.sequence, current.value.clone());
}
result
}
/// Request-local observation token of a stored row: its content digest. A
/// missing row has the all-zero token, which no stored row can collide with
/// except by a digest preimage.
fn row_revision(row: Option<&Value>) -> Revision {
Revision(row.map_or([0; 32], |value| mkit_core::hash::hash(value.as_bytes())))
}
/// How the apply in [`clear`] discharges one guard of the clearance decision.
///
/// The final batch compares the publication row and the changed advance row
/// exactly. Every retained suffix row is written only by [`append`] and
/// [`clear`], and both rewrite the guarded publication row in the same batch,
/// so a suffix row cannot move without failing the publication-row comparison
/// (pinned by `every_advance_writer_rewrites_the_publication_row`). Dependency
/// witnesses are not supplied to the decision: they live in other partitions or
/// under other refs' publication rows and are checked by the dependency walk
/// before the apply, within the batch's `NotAfter` window (SPEC-SERVER 14.4).
fn account_for(decision: &Guarded<ClearanceDecision>) -> Result<(), StoreError> {
for guard in decision.required_guards() {
match guard {
RequiredGuard::Row {
row:
GuardRow::ClearancePrior
| GuardRow::ClearanceChanged(_)
| GuardRow::ClearanceSuffix(_)
| GuardRow::ClearanceLifecycleProfile,
..
} => {}
// A guard this lowering does not know is never silently dropped.
_ => {
return Err(StoreError::Corrupt(
"unaccounted publication clearance guard".into(),
));
}
}
}
Ok(())
}
fn clearance_prefix(
prior: Revision,
state: &Publication,
changed: &Advance,
decoded: &[Advance],
revisions: &[Revision],
) -> Result<(u64, Pair), StoreError> {
let obligations: Vec<Vec<ClearanceState>> = decoded
.iter()
.map(|advance| advance.obligations.iter().map(obligation_state).collect())
.collect();
let obligation_revisions: Vec<Vec<Revision>> = obligations
.iter()
.map(|states| vec![Revision([0; 32]); states.len()])
.collect();
let views: Vec<ClearanceAdvance<'_>> = decoded
.iter()
.zip(obligations.iter())
.zip(obligation_revisions.iter())
.zip(revisions)
.map(|(((advance, states), obligation_revisions), revision)| {
clearance_advance(advance, states, obligation_revisions, *revision)
})
.collect();
let changed_view = views
.iter()
.find(|view| view.sequence == changed.sequence)
.cloned()
.unwrap_or_else(|| clearance_advance(changed, &[], &[], Revision([0; 32])));
let facts = {
let mut v = ClearanceFacts::new(
RepositoryId([0; 32]),
ClearanceMoment::Recheck,
PriorPublication::new(
state.generation,
state.sequence,
state.published,
state.boundary,
state.value.head,
state.value.packmap,
prior,
),
changed_view,
obligation_state_from(changed.state),
);
v.suffix = &views;
v.witnesses = &[];
v.lifecycle = ClearanceLifecycle::NotOffered;
v.deleted = false;
v
};
let verdict = decide_clearance(&facts, &ClearanceLimits::reference())
.map_err(|_| StoreError::Corrupt("publication prefix decision refused".into()))?;
account_for(&verdict)?;
Ok(match verdict.decision() {
ClearanceDecision::AdvancePrefix {
published_prefix,
head,
packmap,
..
} => (
*published_prefix,
Pair {
head: *head,
packmap: *packmap,
},
),
ClearanceDecision::Retain { published_prefix } => (*published_prefix, state.value.clone()),
_ => {
return Err(StoreError::Corrupt(
"unknown publication clearance decision".into(),
));
}
})
}
fn clearance_advance<'a>(
advance: &'a Advance,
obligations: &'a [ClearanceState],
obligation_revisions: &'a [Revision],
revision: Revision,
) -> ClearanceAdvance<'a> {
{
let mut v = ClearanceAdvance::new(
advance.sequence,
advance.generation,
OperationId(advance.operation),
obligation_state_from(advance.state),
revision,
);
v.head = advance.value.head;
v.packmap = advance.value.packmap;
v.additions = &[];
v.dependencies = &[];
v.external_suppliers = &[];
v.obligations = obligations;
v.obligation_revisions = obligation_revisions;
v.takedown_complete = advance.state == Clearance::Resolved;
v.flagged = false;
v
}
}
fn obligation_state(obligation: &Obligation) -> ClearanceState {
obligation_state_from(obligation.state)
}
fn obligation_state_from(state: Clearance) -> ClearanceState {
match state {
Clearance::Pending => ClearanceState::Pending,
Clearance::Cleared => ClearanceState::Cleared,
Clearance::Held => ClearanceState::Held,
Clearance::Hit => ClearanceState::Hit,
Clearance::Resolved => ClearanceState::Resolved,
}
}
/// Guarded clearance fragment. A caller has verified obligations, flags and membership
/// dependencies against current witnesses before calling this function. Resolved
/// advances must already name their completed takedown replacements.
#[allow(clippy::too_many_arguments)]
pub fn clear(
repo: &RepoId,
name: &str,
source: &Partition,
shards: &dyn ShardMap,
state_raw: &Value,
advance_raw: &Value,
changed: &Advance,
eligible: (u64, Pair),
pre: &mut Vec<Precondition>,
writes: &mut Vec<Write>,
outbox: &mut OutboxBuilder,
) -> Result<(), StoreError> {
let name = sequence_ref(name);
let mut state = Publication::decode(Some(state_raw))?;
let old = Advance::decode(advance_raw)?;
changed.validate()?;
if changed.sequence != old.sequence
|| changed.sequence > state.sequence
|| changed.generation != old.generation
|| changed.generation != state.generation
|| eligible.0 < state.published
|| eligible.0 > state.sequence
|| old.state == Clearance::Hit && changed.state == Clearance::Cleared
{
return Err(StoreError::Invalid(
"invalid publication clearance transition".into(),
));
}
let key = keys::advance(&repo.name, &name, changed.sequence);
pre.push(guard(key.clone(), Some(advance_raw)));
writes.push(Write::Put(key, changed.encode()?));
pre.push(guard(keys::publication(&repo.name, &name), Some(state_raw)));
project_members(repo, source, shards, changed, writes, outbox);
if eligible.0 > state.published {
state.published = eligible.0;
state.value = eligible.1;
project_refs(repo, &name, source, shards, &state.value, writes, outbox);
}
writes.push(Write::Put(
keys::publication(&repo.name, &name),
state.encode()?,
));
Ok(())
}
#[cfg(test)]
#[path = "publication_tests.rs"]
mod tests;