Add the lock-owned transactional Domain Store

This commit is contained in:
ms2k 2026-07-15 14:01:46 +09:00
commit e4d9cc2dc9
18 changed files with 1197 additions and 292 deletions

3
Cargo.lock generated
View file

@ -168,6 +168,9 @@ name = "lazaret-host-coordinator"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"lazaret-core", "lazaret-core",
"lazaret-linux",
"redb",
"serde",
] ]
[[package]] [[package]]

View file

@ -206,32 +206,6 @@ impl PidfsProcessIdentityV1 {
} }
} }
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
pub struct LegacyUnverifiedProcessIdentityV1 {
host_pid: HostPidLocator,
start_time_ticks: ProcessStartTimeTicks,
}
impl LegacyUnverifiedProcessIdentityV1 {
#[must_use]
pub const fn new(host_pid: HostPidLocator, start_time_ticks: ProcessStartTimeTicks) -> Self {
Self {
host_pid,
start_time_ticks,
}
}
#[must_use]
pub const fn host_pid(&self) -> HostPidLocator {
self.host_pid
}
#[must_use]
pub const fn start_time_ticks(&self) -> ProcessStartTimeTicks {
self.start_time_ticks
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] #[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
pub struct NamespaceIdentity { pub struct NamespaceIdentity {
pub device: DeviceId, pub device: DeviceId,
@ -252,10 +226,10 @@ mod tests {
use super::{ use super::{
BootId, ContainerId, CoordinatorId, DeviceId, DomainId, GenerationId, GuestProcessId, BootId, ContainerId, CoordinatorId, DeviceId, DomainId, GenerationId, GuestProcessId,
GuestProcessStartTimeTicks, HostPidLocator, LegacyUnverifiedProcessIdentityV1, GuestProcessStartTimeTicks, HostPidLocator, MountIdentity, NamespaceIdentity,
MountIdentity, NamespaceIdentity, NetworkSidecarId, PidfsDeviceId, PidfsInodeId, NetworkSidecarId, PidfsDeviceId, PidfsInodeId, PidfsProcessAuthorityV1,
PidfsProcessAuthorityV1, PidfsProcessIdentityV1, PreparedArtifactId, ProcessStartTimeTicks, PidfsProcessIdentityV1, PreparedArtifactId, ProcessStartTimeTicks, RequestId, SandboxId,
RequestId, SandboxId, SessionId, SessionId,
}; };
#[test] #[test]
@ -273,7 +247,6 @@ mod tests {
TypeId::of::<BootId>(), TypeId::of::<BootId>(),
TypeId::of::<PidfsProcessAuthorityV1>(), TypeId::of::<PidfsProcessAuthorityV1>(),
TypeId::of::<PidfsProcessIdentityV1>(), TypeId::of::<PidfsProcessIdentityV1>(),
TypeId::of::<LegacyUnverifiedProcessIdentityV1>(),
TypeId::of::<HostPidLocator>(), TypeId::of::<HostPidLocator>(),
TypeId::of::<ProcessStartTimeTicks>(), TypeId::of::<ProcessStartTimeTicks>(),
TypeId::of::<GuestProcessId>(), TypeId::of::<GuestProcessId>(),

View file

@ -8,11 +8,10 @@ use crate::identity::{
BundleDigest, CapabilityId, CertificateDigest, ClientId, ConfigurationFingerprint, ContainerId, BundleDigest, CapabilityId, CertificateDigest, ClientId, ConfigurationFingerprint, ContainerId,
CoordinatorId, DerivedPolicyDigest, DeviceId, DomainId, FileDescriptor, GenerationId, CoordinatorId, DerivedPolicyDigest, DeviceId, DomainId, FileDescriptor, GenerationId,
GuestProcessGroupId, GuestProcessId, GuestProcessStartTimeTicks, HostPath, IdMapDigest, GuestProcessGroupId, GuestProcessId, GuestProcessStartTimeTicks, HostPath, IdMapDigest,
InodeId, LegacyUnverifiedProcessIdentityV1, MountIdentity, NamespaceIdentity, NetworkSidecarId, InodeId, MountIdentity, NamespaceIdentity, NetworkSidecarId, PidfsProcessAuthorityV1,
PidfsProcessAuthorityV1, PidfsProcessIdentityV1, PolicyId, PrepareOperationId, PidfsProcessIdentityV1, PolicyId, PrepareOperationId, PreparedArtifactId, ProtocolCredentialId,
PreparedArtifactId, ProtocolCredentialId, ReadinessFingerprint, RequestDigest, RequestId, ReadinessFingerprint, RequestDigest, RequestId, SandboxId, SelectedManifestDigest,
SandboxId, SelectedManifestDigest, SessionAnchorId, SessionId, SourceIdentity, StreamDigest, SessionAnchorId, SessionId, SourceIdentity, StreamDigest, TrustManifestDigest,
TrustManifestDigest,
}; };
mod configuration; mod configuration;

View file

@ -1,10 +1,8 @@
use super::{BTreeMap, Deserialize, HostPath, Serialize}; use super::{BTreeMap, Deserialize, HostPath, Serialize};
pub const DOMAIN_STORE_SCHEMA_ID: &str = "lazaret-domain-store-v2"; pub const DOMAIN_STORE_SCHEMA_ID: &str = "lazaret-domain-store-v1";
pub const LEGACY_DOMAIN_STORE_SCHEMA_ID: &str = "lazaret-domain-store-v1";
pub const STORAGE_CATALOG_SCHEMA_ID: &str = "lazaret-storage-catalog-v1"; pub const STORAGE_CATALOG_SCHEMA_ID: &str = "lazaret-storage-catalog-v1";
pub const DOMAIN_STORE_SCHEMA_VERSION: u32 = 2; pub const DOMAIN_STORE_SCHEMA_VERSION: u32 = 1;
pub const LEGACY_DOMAIN_STORE_SCHEMA_VERSION: u32 = 1;
pub const STORAGE_CATALOG_SCHEMA_VERSION: u32 = 1; pub const STORAGE_CATALOG_SCHEMA_VERSION: u32 = 1;
pub const TRUSTED_HOST_CONFIGURATION_SCHEMA_ID: &str = "lazaret-trusted-host-config-v1"; pub const TRUSTED_HOST_CONFIGURATION_SCHEMA_ID: &str = "lazaret-trusted-host-config-v1";

View file

@ -2,11 +2,10 @@ use super::{
BundleDigest, ClientId, ConfigurationFingerprint, ContainerId, CoordinatorId, BundleDigest, ClientId, ConfigurationFingerprint, ContainerId, CoordinatorId,
DOMAIN_STORE_SCHEMA_ID, DOMAIN_STORE_SCHEMA_VERSION, DerivedPolicyDigest, Deserialize, DOMAIN_STORE_SCHEMA_ID, DOMAIN_STORE_SCHEMA_VERSION, DerivedPolicyDigest, Deserialize,
DeviceId, DomainId, FileDescriptor, GenerationId, GuestProcessGroupId, GuestProcessId, DeviceId, DomainId, FileDescriptor, GenerationId, GuestProcessGroupId, GuestProcessId,
GuestProcessStartTimeTicks, HostPath, InodeId, LEGACY_DOMAIN_STORE_SCHEMA_ID, GuestProcessStartTimeTicks, HostPath, InodeId, MountIdentity, NamespaceIdentity,
LEGACY_DOMAIN_STORE_SCHEMA_VERSION, LegacyUnverifiedProcessIdentityV1, MountIdentity, NetworkReadinessEvidence, NetworkSidecarId, PidfsProcessIdentityV1, PreparedArtifactId,
NamespaceIdentity, NetworkReadinessEvidence, NetworkSidecarId, PidfsProcessIdentityV1, ReadinessFingerprint, RecordInvariantError, RecordTransitionError, RequestId,
PreparedArtifactId, ReadinessFingerprint, RecordInvariantError, RecordTransitionError, STORAGE_CATALOG_SCHEMA_ID, STORAGE_CATALOG_SCHEMA_VERSION, SandboxId, Serialize,
RequestId, STORAGE_CATALOG_SCHEMA_ID, STORAGE_CATALOG_SCHEMA_VERSION, SandboxId, Serialize,
SessionAnchorId, SessionId, SourceIdentity, fmt, SessionAnchorId, SessionId, SourceIdentity, fmt,
}; };
@ -18,21 +17,13 @@ pub struct StoreMetadata {
impl StoreMetadata { impl StoreMetadata {
#[must_use] #[must_use]
pub fn domain_store_v2() -> Self { pub fn domain_store_v1() -> Self {
Self { Self {
schema_id: DOMAIN_STORE_SCHEMA_ID.into(), schema_id: DOMAIN_STORE_SCHEMA_ID.into(),
schema_version: DOMAIN_STORE_SCHEMA_VERSION, schema_version: DOMAIN_STORE_SCHEMA_VERSION,
} }
} }
#[must_use]
pub fn legacy_domain_store_v1() -> Self {
Self {
schema_id: LEGACY_DOMAIN_STORE_SCHEMA_ID.into(),
schema_version: LEGACY_DOMAIN_STORE_SCHEMA_VERSION,
}
}
#[must_use] #[must_use]
pub fn storage_catalog_v1() -> Self { pub fn storage_catalog_v1() -> Self {
Self { Self {
@ -56,11 +47,6 @@ impl StoreMetadata {
&& self.schema_version == DOMAIN_STORE_SCHEMA_VERSION && self.schema_version == DOMAIN_STORE_SCHEMA_VERSION
} }
fn is_legacy_domain_store(&self) -> bool {
self.schema_id == LEGACY_DOMAIN_STORE_SCHEMA_ID
&& self.schema_version == LEGACY_DOMAIN_STORE_SCHEMA_VERSION
}
fn is_storage_catalog(&self) -> bool { fn is_storage_catalog(&self) -> bool {
self.schema_id == STORAGE_CATALOG_SCHEMA_ID self.schema_id == STORAGE_CATALOG_SCHEMA_ID
&& self.schema_version == STORAGE_CATALOG_SCHEMA_VERSION && self.schema_version == STORAGE_CATALOG_SCHEMA_VERSION
@ -83,10 +69,7 @@ impl<'de> Deserialize<'de> for StoreMetadata {
schema_id: fields.schema_id, schema_id: fields.schema_id,
schema_version: fields.schema_version, schema_version: fields.schema_version,
}; };
if metadata.is_domain_store() if metadata.is_domain_store() || metadata.is_storage_catalog() {
|| metadata.is_legacy_domain_store()
|| metadata.is_storage_catalog()
{
Ok(metadata) Ok(metadata)
} else { } else {
Err(serde::de::Error::custom( Err(serde::de::Error::custom(
@ -100,24 +83,9 @@ impl<'de> Deserialize<'de> for StoreMetadata {
pub struct DomainStoreMetadata(StoreMetadata); pub struct DomainStoreMetadata(StoreMetadata);
impl DomainStoreMetadata { impl DomainStoreMetadata {
#[must_use]
pub fn v2() -> Self {
Self(StoreMetadata::domain_store_v2())
}
#[must_use]
pub const fn as_metadata(&self) -> &StoreMetadata {
&self.0
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct LegacyDomainStoreMetadata(StoreMetadata);
impl LegacyDomainStoreMetadata {
#[must_use] #[must_use]
pub fn v1() -> Self { pub fn v1() -> Self {
Self(StoreMetadata::legacy_domain_store_v1()) Self(StoreMetadata::domain_store_v1())
} }
#[must_use] #[must_use]
@ -126,22 +94,6 @@ impl LegacyDomainStoreMetadata {
} }
} }
impl<'de> Deserialize<'de> for LegacyDomainStoreMetadata {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let metadata = StoreMetadata::deserialize(deserializer)?;
if metadata.is_legacy_domain_store() {
Ok(Self(metadata))
} else {
Err(serde::de::Error::custom(
"expected legacy Domain Store v1 metadata",
))
}
}
}
impl<'de> Deserialize<'de> for DomainStoreMetadata { impl<'de> Deserialize<'de> for DomainStoreMetadata {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where where
@ -235,42 +187,17 @@ pub enum ParticipantState {
} }
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum HostProcessIdentity { pub struct HostProcessIdentity(PidfsProcessIdentityV1);
PidfsV1(PidfsProcessIdentityV1),
LegacyUnverifiedV1(LegacyUnverifiedProcessIdentityV1),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct LegacyProcessIdentityRefusal;
impl fmt::Display for LegacyProcessIdentityRefusal {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("legacy PID/start-only process identity has no positive authority")
}
}
impl std::error::Error for LegacyProcessIdentityRefusal {}
impl HostProcessIdentity { impl HostProcessIdentity {
#[must_use] #[must_use]
pub const fn pidfs_v1(identity: PidfsProcessIdentityV1) -> Self { pub const fn pidfs_v1(identity: PidfsProcessIdentityV1) -> Self {
Self::PidfsV1(identity) Self(identity)
} }
#[must_use] #[must_use]
pub const fn legacy_unverified_v1(identity: LegacyUnverifiedProcessIdentityV1) -> Self { pub const fn pidfs(&self) -> &PidfsProcessIdentityV1 {
Self::LegacyUnverifiedV1(identity) &self.0
}
/// # Errors
/// Legacy PID/start-only data has no positive authority.
pub const fn verified_pidfs(
&self,
) -> Result<&PidfsProcessIdentityV1, LegacyProcessIdentityRefusal> {
match self {
Self::PidfsV1(identity) => Ok(identity),
Self::LegacyUnverifiedV1(_) => Err(LegacyProcessIdentityRefusal),
}
} }
} }
@ -297,11 +224,6 @@ impl PersistedHostProcessOwnership {
Self::Committed(HostProcessIdentity::pidfs_v1(identity)) Self::Committed(HostProcessIdentity::pidfs_v1(identity))
} }
#[must_use]
pub const fn legacy_unverified(identity: LegacyUnverifiedProcessIdentityV1) -> Self {
Self::Committed(HostProcessIdentity::legacy_unverified_v1(identity))
}
#[must_use] #[must_use]
pub const fn is_staged(&self) -> bool { pub const fn is_staged(&self) -> bool {
matches!(self, Self::Staged(_)) matches!(self, Self::Staged(_))
@ -314,7 +236,7 @@ impl PersistedHostProcessOwnership {
#[must_use] #[must_use]
pub const fn is_current_committed(&self) -> bool { pub const fn is_current_committed(&self) -> bool {
matches!(self, Self::Committed(HostProcessIdentity::PidfsV1(_))) matches!(self, Self::Committed(_))
} }
const fn is_initial_acquisition(&self) -> bool { const fn is_initial_acquisition(&self) -> bool {
@ -326,29 +248,22 @@ impl PersistedHostProcessOwnership {
} }
/// # Errors /// # Errors
/// Unassigned or legacy-unverified ownership has no positive authority. /// Unassigned ownership has no positive authority.
pub fn authoritative_pidfs(&self) -> Result<&PidfsProcessIdentityV1, ProcessAuthorityRefusal> { pub fn authoritative_pidfs(&self) -> Result<&PidfsProcessIdentityV1, ProcessAuthorityRefusal> {
match self { match self {
Self::Unassigned => Err(ProcessAuthorityRefusal::Unassigned), Self::Unassigned => Err(ProcessAuthorityRefusal::Unassigned),
Self::Staged(identity) | Self::Committed(HostProcessIdentity::PidfsV1(identity)) => { Self::Staged(identity) => Ok(identity),
Ok(identity) Self::Committed(identity) => Ok(identity.pidfs()),
}
Self::Committed(HostProcessIdentity::LegacyUnverifiedV1(_)) => {
Err(ProcessAuthorityRefusal::LegacyUnverified)
}
} }
} }
/// # Errors /// # Errors
/// Unassigned, staged, or legacy-unverified ownership is not serviceable. /// Unassigned or staged ownership is not serviceable.
pub fn serviceable_pidfs(&self) -> Result<&PidfsProcessIdentityV1, ProcessOwnershipRefusal> { pub fn serviceable_pidfs(&self) -> Result<&PidfsProcessIdentityV1, ProcessOwnershipRefusal> {
match self { match self {
Self::Unassigned => Err(ProcessOwnershipRefusal::Unassigned), Self::Unassigned => Err(ProcessOwnershipRefusal::Unassigned),
Self::Staged(_) => Err(ProcessOwnershipRefusal::Staged), Self::Staged(_) => Err(ProcessOwnershipRefusal::Staged),
Self::Committed(HostProcessIdentity::PidfsV1(identity)) => Ok(identity), Self::Committed(identity) => Ok(identity.pidfs()),
Self::Committed(HostProcessIdentity::LegacyUnverifiedV1(_)) => {
Err(ProcessOwnershipRefusal::LegacyUnverified)
}
} }
} }
} }
@ -357,7 +272,6 @@ impl PersistedHostProcessOwnership {
pub enum ProcessOwnershipRefusal { pub enum ProcessOwnershipRefusal {
Unassigned, Unassigned,
Staged, Staged,
LegacyUnverified,
} }
impl fmt::Display for ProcessOwnershipRefusal { impl fmt::Display for ProcessOwnershipRefusal {
@ -365,8 +279,6 @@ impl fmt::Display for ProcessOwnershipRefusal {
match self { match self {
Self::Unassigned => formatter.write_str("process ownership is unassigned"), Self::Unassigned => formatter.write_str("process ownership is unassigned"),
Self::Staged => formatter.write_str("process ownership is staged, not committed"), Self::Staged => formatter.write_str("process ownership is staged, not committed"),
Self::LegacyUnverified => formatter
.write_str("legacy PID/start-only process ownership has no positive authority"),
} }
} }
} }
@ -376,15 +288,12 @@ impl std::error::Error for ProcessOwnershipRefusal {}
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ProcessAuthorityRefusal { pub enum ProcessAuthorityRefusal {
Unassigned, Unassigned,
LegacyUnverified,
} }
impl fmt::Display for ProcessAuthorityRefusal { impl fmt::Display for ProcessAuthorityRefusal {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Self::Unassigned => formatter.write_str("process ownership is unassigned"), Self::Unassigned => formatter.write_str("process ownership is unassigned"),
Self::LegacyUnverified => formatter
.write_str("legacy PID/start-only process ownership has no positive authority"),
} }
} }
} }
@ -1507,8 +1416,8 @@ fn validate_process_progress(
) if old == next => Ok(()), ) if old == next => Ok(()),
( (
PersistedHostProcessOwnership::Staged(old), PersistedHostProcessOwnership::Staged(old),
PersistedHostProcessOwnership::Committed(HostProcessIdentity::PidfsV1(next)), PersistedHostProcessOwnership::Committed(next),
) if old == next => Ok(()), ) if old == next.pidfs() => Ok(()),
( (
PersistedHostProcessOwnership::Committed(old), PersistedHostProcessOwnership::Committed(old),
PersistedHostProcessOwnership::Committed(next), PersistedHostProcessOwnership::Committed(next),
@ -1525,7 +1434,7 @@ fn validate_existing_process_progress(
(old, next), (old, next),
( (
PersistedHostProcessOwnership::Unassigned, PersistedHostProcessOwnership::Unassigned,
PersistedHostProcessOwnership::Committed(HostProcessIdentity::PidfsV1(_)) PersistedHostProcessOwnership::Committed(_)
) )
) { ) {
Ok(()) Ok(())

View file

@ -4,20 +4,20 @@ use super::{
DerivedNetworkPolicy, DeveloperDecision, DeveloperDecisionKind, DnsObservation, Domain, DerivedNetworkPolicy, DeveloperDecision, DeveloperDecisionKind, DnsObservation, Domain,
DomainStore, EnvironmentReference, EscalationOutcome, Generation, GenerationIdentity, DomainStore, EnvironmentReference, EscalationOutcome, Generation, GenerationIdentity,
GitRoutingOwnership, GuestProcessIdentity, HelperOwnership, HostProcessIdentity, GitRoutingOwnership, GuestProcessIdentity, HelperOwnership, HostProcessIdentity,
HostProcessOwnershipSlot, LegacyUnverifiedProcessIdentityV1, Mailbox, MailboxCancellation, HostProcessOwnershipSlot, Mailbox, MailboxCancellation, MailboxRequest, MailboxResult,
MailboxRequest, MailboxResult, MailboxResultAck, MailboxResultChunk, MountIdentity, MailboxResultAck, MailboxResultChunk, MountIdentity, NamespaceIdentity, NetworkObservation,
NamespaceIdentity, NetworkObservation, NetworkOwnership, NetworkReachabilityProbe, NetworkOwnership, NetworkReachabilityProbe, NetworkReadinessAttestation,
NetworkReadinessAttestation, NetworkReadinessEvidence, NetworkReadinessIdentity, Participant, NetworkReadinessEvidence, NetworkReadinessIdentity, Participant, ParticipantOwnership,
ParticipantOwnership, ParticipantState, PermittedDestinationObservation, ParticipantState, PermittedDestinationObservation, PersistedHostProcessOwnership,
PersistedHostProcessOwnership, PidfsProcessAuthorityV1, PidfsProcessIdentityV1, PolicyAction, PidfsProcessAuthorityV1, PidfsProcessIdentityV1, PolicyAction, PolicyDelivery, PrefixRule,
PolicyDelivery, PrefixRule, PrepareOperation, PreparedArtifact, PreparedLowerOwnership, PrepareOperation, PreparedArtifact, PreparedLowerOwnership, ProcessOwnershipCommitAck,
ProcessOwnershipCommitAck, ProcessOwnershipMessageVersion, ProtocolCredential, ProcessOwnershipMessageVersion, ProtocolCredential, ProtocolCredentialRole,
ProtocolCredentialRole, ProtocolPublicationIdentity, PtyIdentity, PublicationOwnership, ProtocolPublicationIdentity, PtyIdentity, PublicationOwnership, ResolvedAutoApprovalRule,
ResolvedAutoApprovalRule, ResolvedVolume, Resolver, ResultStream, SandboxCredentialBundle, ResolvedVolume, Resolver, ResultStream, SandboxCredentialBundle, SandboxOwnership, Session,
SandboxOwnership, Session, SessionBinding, SessionClose, SessionExit, SessionExitOutcome, SessionBinding, SessionClose, SessionExit, SessionExitOutcome, SessionRequestCapability,
SessionRequestCapability, SessionTerminal, SessionTerminalEvent, SessionTerminalMessage, SessionTerminal, SessionTerminalEvent, SessionTerminalMessage, SessionTerminalState,
SessionTerminalState, SignedProtocolRecord, StagedProcessOwnership, StateAnchorOwnership, SignedProtocolRecord, StagedProcessOwnership, StateAnchorOwnership, StorageCatalog,
StorageCatalog, StoreMetadata, TerminalInput, TerminalOutput, TerminalResize, TerminalSignal, StoreMetadata, TerminalInput, TerminalOutput, TerminalResize, TerminalSignal,
TerminalSignalKind, TlsCredential, TlsRole, TrustManifest, TrustedHostConfigurationV1, TerminalSignalKind, TlsCredential, TlsRole, TrustManifest, TrustedHostConfigurationV1,
}; };
@ -55,7 +55,6 @@ schema_types!(
"PtyIdentity" => PtyIdentity, "PtyIdentity" => PtyIdentity,
"PidfsProcessAuthorityV1" => PidfsProcessAuthorityV1, "PidfsProcessAuthorityV1" => PidfsProcessAuthorityV1,
"PidfsProcessIdentityV1" => PidfsProcessIdentityV1, "PidfsProcessIdentityV1" => PidfsProcessIdentityV1,
"LegacyUnverifiedProcessIdentityV1" => LegacyUnverifiedProcessIdentityV1,
"HostProcessIdentity" => HostProcessIdentity, "HostProcessIdentity" => HostProcessIdentity,
"PersistedHostProcessOwnership" => PersistedHostProcessOwnership, "PersistedHostProcessOwnership" => PersistedHostProcessOwnership,
"HostProcessOwnershipSlot" => HostProcessOwnershipSlot, "HostProcessOwnershipSlot" => HostProcessOwnershipSlot,

View file

@ -8,12 +8,11 @@ use lazaret_core::identity::{
BootId, BundleDigest, CapabilityId, CertificateDigest, ClientId, ConfigurationFingerprint, BootId, BundleDigest, CapabilityId, CertificateDigest, ClientId, ConfigurationFingerprint,
ContainerId, CoordinatorId, DerivedPolicyDigest, DeviceId, DomainId, FileDescriptor, ContainerId, CoordinatorId, DerivedPolicyDigest, DeviceId, DomainId, FileDescriptor,
GenerationId, GuestProcessGroupId, GuestProcessId, GuestProcessStartTimeTicks, HostPath, GenerationId, GuestProcessGroupId, GuestProcessId, GuestProcessStartTimeTicks, HostPath,
HostPidLocator, IdMapDigest, InodeId, LegacyUnverifiedProcessIdentityV1, MountId, HostPidLocator, IdMapDigest, InodeId, MountId, MountIdentity, NamespaceIdentity,
MountIdentity, NamespaceIdentity, NetworkSidecarId, PidfsDeviceId, PidfsInodeId, NetworkSidecarId, PidfsDeviceId, PidfsInodeId, PidfsProcessAuthorityV1, PidfsProcessIdentityV1,
PidfsProcessAuthorityV1, PidfsProcessIdentityV1, PolicyId, PrepareOperationId, PolicyId, PrepareOperationId, PreparedArtifactId, ProcessStartTimeTicks, ProtocolCredentialId,
PreparedArtifactId, ProcessStartTimeTicks, ProtocolCredentialId, ReadinessFingerprint, ReadinessFingerprint, RequestDigest, RequestId, SandboxId, SelectedManifestDigest,
RequestDigest, RequestId, SandboxId, SelectedManifestDigest, SessionAnchorId, SessionId, SessionAnchorId, SessionId, SourceIdentity, StreamDigest, TrustManifestDigest,
SourceIdentity, StreamDigest, TrustManifestDigest,
}; };
use lazaret_core::records::*; use lazaret_core::records::*;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;

View file

@ -40,22 +40,8 @@ fn store_identity_and_versions_dispatch_before_rows() {
decode::<DomainStoreMetadata>(&encode(&StoreMetadata::storage_catalog_v1()).unwrap()), decode::<DomainStoreMetadata>(&encode(&StoreMetadata::storage_catalog_v1()).unwrap()),
Err(CodecError::MalformedPayload(_)) Err(CodecError::MalformedPayload(_))
)); ));
for domain_metadata in [
StoreMetadata::domain_store_v2(),
StoreMetadata::legacy_domain_store_v1(),
] {
assert!(matches!(
decode::<StorageCatalogMetadata>(&encode(&domain_metadata).unwrap()),
Err(CodecError::MalformedPayload(_))
));
}
let legacy = StoreMetadata::legacy_domain_store_v1();
assert_eq!(
decode::<LegacyDomainStoreMetadata>(&encode(&legacy).unwrap()).unwrap(),
LegacyDomainStoreMetadata::v1()
);
assert!(matches!( assert!(matches!(
decode::<DomainStoreMetadata>(&encode(&legacy).unwrap()), decode::<StorageCatalogMetadata>(&encode(&StoreMetadata::domain_store_v1()).unwrap()),
Err(CodecError::MalformedPayload(_)) Err(CodecError::MalformedPayload(_))
)); ));
@ -79,7 +65,7 @@ fn store_identity_and_versions_dispatch_before_rows() {
)); ));
let wrong_catalog = postcard::to_stdvec(&RawStorageCatalog { let wrong_catalog = postcard::to_stdvec(&RawStorageCatalog {
metadata: StoreMetadata::domain_store_v2(), metadata: StoreMetadata::domain_store_v1(),
prepared_artifacts: vec![], prepared_artifacts: vec![],
artifact_leases: vec![], artifact_leases: vec![],
prepare_operations: vec![], prepare_operations: vec![],

View file

@ -57,7 +57,7 @@ fn every_schema_shape_round_trips_and_rejects_malformed_or_unknown_versions() {
outbox_path: HostPath::new(b"outbox".to_vec()), outbox_path: HostPath::new(b"outbox".to_vec()),
state: ParticipantState::Committed, state: ParticipantState::Committed,
}; };
let metadata = StoreMetadata::domain_store_v2(); let metadata = StoreMetadata::domain_store_v1();
let tls_credential = TlsCredential::session_coordinator( let tls_credential = TlsCredential::session_coordinator(
generation_identity(), generation_identity(),
coordinator_id(), coordinator_id(),
@ -110,13 +110,6 @@ fn every_schema_shape_round_trips_and_rejects_malformed_or_unknown_versions() {
); );
verify_shape(pidfs_identity.authority(), &mut seen); verify_shape(pidfs_identity.authority(), &mut seen);
verify_shape(pidfs_identity, &mut seen); verify_shape(pidfs_identity, &mut seen);
verify_shape(
LegacyUnverifiedProcessIdentityV1::new(
HostPidLocator::new(48),
ProcessStartTimeTicks::new(480),
),
&mut seen,
);
verify_shape(HostProcessIdentity::pidfs_v1(pidfs_identity), &mut seen); verify_shape(HostProcessIdentity::pidfs_v1(pidfs_identity), &mut seen);
verify_shape( verify_shape(
PersistedHostProcessOwnership::staged(pidfs_identity), PersistedHostProcessOwnership::staged(pidfs_identity),
@ -442,7 +435,7 @@ fn every_schema_shape_round_trips_and_rejects_malformed_or_unknown_versions() {
); );
verify_shape( verify_shape(
DomainStore { DomainStore {
metadata: DomainStoreMetadata::v2(), metadata: DomainStoreMetadata::v1(),
domain, domain,
generations: vec![generation], generations: vec![generation],
participants: vec![participant], participants: vec![participant],
@ -729,7 +722,7 @@ fn trailing_and_nonminimal_postcard_encodings_are_rejected() {
Err(CodecError::NonMinimalPayload) Err(CodecError::NonMinimalPayload)
)); ));
let mut overlong_store_id = encode(&StoreMetadata::domain_store_v2()).unwrap(); let mut overlong_store_id = encode(&StoreMetadata::domain_store_v1()).unwrap();
overlong_store_id[0] |= 0x80; overlong_store_id[0] |= 0x80;
overlong_store_id.insert(1, 0); overlong_store_id.insert(1, 0);
assert!(matches!( assert!(matches!(

View file

@ -136,21 +136,6 @@ fn newly_recorded_existing_processes_do_not_bypass_child_staging() {
Ok(()), Ok(()),
"{edge:?}" "{edge:?}"
); );
let legacy = participant_edge_snapshot(
edge,
PersistedHostProcessOwnership::legacy_unverified(
LegacyUnverifiedProcessIdentityV1::new(
current.host_pid(),
current.start_time_ticks(),
),
),
);
assert_eq!(
unassigned.validate_transition_to(&legacy),
Err(RecordTransitionError::ProcessStageSkipped),
"{edge:?}"
);
} }
let acquired_edges = [ let acquired_edges = [
@ -212,21 +197,6 @@ fn newly_recorded_existing_processes_do_not_bypass_child_staging() {
terminal_unassigned.validate_transition_to(&terminal_existing_hc), terminal_unassigned.validate_transition_to(&terminal_existing_hc),
Ok(()) Ok(())
); );
let terminal_legacy_hc = terminal_snapshot(
SessionTerminalState::Intent,
PersistedHostProcessOwnership::legacy_unverified(LegacyUnverifiedProcessIdentityV1::new(
HostPidLocator::new(101),
ProcessStartTimeTicks::new(1_010),
)),
PersistedHostProcessOwnership::unassigned(),
false,
)
.unwrap();
assert_eq!(
terminal_unassigned.validate_transition_to(&terminal_legacy_hc),
Err(RecordTransitionError::ProcessStageSkipped)
);
let terminal_exec_staged = terminal_snapshot( let terminal_exec_staged = terminal_snapshot(
SessionTerminalState::Intent, SessionTerminalState::Intent,
owned_process(101), owned_process(101),

View file

@ -77,22 +77,7 @@ fn process_slot_separates_cleanup_authority_from_serviceability() {
Err(ProcessAuthorityRefusal::Unassigned) Err(ProcessAuthorityRefusal::Unassigned)
); );
let legacy = LegacyUnverifiedProcessIdentityV1::new( assert_eq!(HostProcessIdentity::pidfs_v1(current).pidfs(), &current);
HostPidLocator::new(91),
ProcessStartTimeTicks::new(910),
);
assert_eq!(
HostProcessIdentity::legacy_unverified_v1(legacy).verified_pidfs(),
Err(LegacyProcessIdentityRefusal)
);
assert_eq!(
PersistedHostProcessOwnership::legacy_unverified(legacy).serviceable_pidfs(),
Err(ProcessOwnershipRefusal::LegacyUnverified)
);
assert_eq!(
PersistedHostProcessOwnership::legacy_unverified(legacy).authoritative_pidfs(),
Err(ProcessAuthorityRefusal::LegacyUnverified)
);
let staged_anchor = ParticipantOwnership::StateAnchor(state_anchor_ownership( let staged_anchor = ParticipantOwnership::StateAnchor(state_anchor_ownership(
PersistedHostProcessOwnership::staged(current), PersistedHostProcessOwnership::staged(current),
@ -267,14 +252,9 @@ fn participant_snapshot_matrix_is_phase_complete_and_strictly_decoded() {
#[test] #[test]
fn active_process_owners_require_complete_current_pidfs_evidence() { fn active_process_owners_require_complete_current_pidfs_evidence() {
let current = process(94); let current = process(94);
let legacy = LegacyUnverifiedProcessIdentityV1::new(
HostPidLocator::new(94),
ProcessStartTimeTicks::new(940),
);
let invalid_slots = [ let invalid_slots = [
PersistedHostProcessOwnership::unassigned(), PersistedHostProcessOwnership::unassigned(),
PersistedHostProcessOwnership::staged(current), PersistedHostProcessOwnership::staged(current),
PersistedHostProcessOwnership::legacy_unverified(legacy),
]; ];
for daemon in invalid_slots { for daemon in invalid_slots {
assert_eq!( assert_eq!(

View file

@ -5,10 +5,6 @@ use super::*;
fn session_terminal_snapshot_and_transition_matrix_is_fail_closed() { fn session_terminal_snapshot_and_transition_matrix_is_fail_closed() {
let current_hc = process(101); let current_hc = process(101);
let current_exec = process(102); let current_exec = process(102);
let legacy = LegacyUnverifiedProcessIdentityV1::new(
HostPidLocator::new(102),
ProcessStartTimeTicks::new(1_020),
);
let bare = terminal_snapshot( let bare = terminal_snapshot(
SessionTerminalState::Intent, SessionTerminalState::Intent,
PersistedHostProcessOwnership::unassigned(), PersistedHostProcessOwnership::unassigned(),
@ -19,7 +15,6 @@ fn session_terminal_snapshot_and_transition_matrix_is_fail_closed() {
for invalid in [ for invalid in [
PersistedHostProcessOwnership::unassigned(), PersistedHostProcessOwnership::unassigned(),
PersistedHostProcessOwnership::staged(current_exec), PersistedHostProcessOwnership::staged(current_exec),
PersistedHostProcessOwnership::legacy_unverified(legacy),
] { ] {
assert_eq!( assert_eq!(
terminal_snapshot( terminal_snapshot(

View file

@ -11,6 +11,11 @@ path = "src/main.rs"
[dependencies] [dependencies]
lazaret-core = { version = "=0.0.0", path = "../lazaret-core" } lazaret-core = { version = "=0.0.0", path = "../lazaret-core" }
lazaret-linux = { version = "=0.0.0", path = "../lazaret-linux" }
redb.workspace = true
[dev-dependencies]
serde.workspace = true
[lints] [lints]
workspace = true workspace = true

View file

@ -0,0 +1,740 @@
use std::fmt;
use std::io;
use std::path::Path;
use lazaret_core::codec::{CodecError, decode, encode};
use lazaret_core::identity::{GenerationId, SessionId};
use lazaret_core::records::{
Domain, DomainStore, DomainStoreMetadata, DurableDecision, Generation, Mailbox, Participant,
ParticipantRole, ParticipantState, RecordTransitionError, Session, SessionPhase,
SessionTerminal, SessionTerminalState,
};
use lazaret_linux::filesystem::ExclusiveFileLock;
use redb::{
CommitError, Database, DatabaseError, ReadableDatabase, ReadableTable, StorageError,
TableDefinition, TableError, TransactionError,
};
const METADATA: TableDefinition<u8, &[u8]> = TableDefinition::new("metadata");
const DOMAINS: TableDefinition<u8, &[u8]> = TableDefinition::new("domains");
const GENERATIONS: TableDefinition<u64, &[u8]> = TableDefinition::new("generations");
const PARTICIPANTS: TableDefinition<&[u8; 9], &[u8]> = TableDefinition::new("participants");
const SESSIONS: TableDefinition<&[u8; 40], &[u8]> = TableDefinition::new("sessions");
const SESSION_TERMINALS: TableDefinition<&[u8; 40], &[u8]> =
TableDefinition::new("session_terminals");
const MAILBOXES: TableDefinition<u64, &[u8]> = TableDefinition::new("mailboxes");
const SINGLETON_KEY: u8 = 0;
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DomainStoreMutation {
PutGeneration(Generation),
PutParticipant(Participant),
CleanParticipantAfterAbsence(Participant),
PutSession(Session),
PutMailbox(Mailbox),
CleanMailboxAfterAbsence(Mailbox),
PutSessionTerminal(SessionTerminal),
CleanSessionTerminalAfterAbsence(SessionTerminal),
}
#[derive(Debug)]
pub enum DomainStoreError {
Io(io::Error),
Database(DatabaseError),
Transaction(TransactionError),
Table(TableError),
Storage(StorageError),
Commit(CommitError),
Codec(CodecError),
Transition(RecordTransitionError),
MissingMetadata,
MissingRow(&'static str),
DuplicateRow(&'static str),
InvalidRow(&'static str),
DomainMismatch,
GenerationMismatch,
}
impl fmt::Display for DomainStoreError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(error) => write!(formatter, "Domain Store I/O failed: {error}"),
Self::Database(error) => write!(formatter, "Domain Store open failed: {error}"),
Self::Transaction(error) => {
write!(formatter, "Domain Store transaction failed: {error}")
}
Self::Table(error) => write!(formatter, "Domain Store table failed: {error}"),
Self::Storage(error) => write!(formatter, "Domain Store storage failed: {error}"),
Self::Commit(error) => write!(formatter, "Domain Store commit failed: {error}"),
Self::Codec(error) => {
write!(formatter, "Domain Store record failed validation: {error}")
}
Self::Transition(error) => {
write!(formatter, "Domain Store transition refused: {error}")
}
Self::MissingMetadata => formatter.write_str("Domain Store metadata is missing"),
Self::MissingRow(kind) => write!(formatter, "referenced {kind} row is missing"),
Self::DuplicateRow(kind) => write!(formatter, "duplicate {kind} row"),
Self::InvalidRow(kind) => write!(formatter, "invalid {kind} row"),
Self::DomainMismatch => formatter.write_str("Domain Store Domain identity mismatch"),
Self::GenerationMismatch => formatter.write_str("Domain Store generation mismatch"),
}
}
}
impl std::error::Error for DomainStoreError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(error) => Some(error),
Self::Database(error) => Some(error),
Self::Transaction(error) => Some(error),
Self::Table(error) => Some(error),
Self::Storage(error) => Some(error),
Self::Commit(error) => Some(error),
Self::Codec(error) => Some(error),
Self::Transition(error) => Some(error),
Self::MissingMetadata
| Self::MissingRow(_)
| Self::DuplicateRow(_)
| Self::InvalidRow(_)
| Self::DomainMismatch
| Self::GenerationMismatch => None,
}
}
}
macro_rules! from_error {
($source:ty, $variant:ident) => {
impl From<$source> for DomainStoreError {
fn from(error: $source) -> Self {
Self::$variant(error)
}
}
};
}
from_error!(io::Error, Io);
from_error!(DatabaseError, Database);
from_error!(TransactionError, Transaction);
from_error!(TableError, Table);
from_error!(StorageError, Storage);
from_error!(CommitError, Commit);
from_error!(CodecError, Codec);
from_error!(RecordTransitionError, Transition);
pub struct HostCoordinatorDomainStore {
database: Database,
_domain_lock: ExclusiveFileLock,
}
impl HostCoordinatorDomainStore {
/// Opens the Domain Store while retaining the caller's exclusive Domain lock.
///
/// # Errors
///
/// Refuses I/O, database, metadata, row, or Domain identity failures.
pub fn open(
domain_lock: ExclusiveFileLock,
database_path: &Path,
expected_domain: &Domain,
) -> Result<Self, DomainStoreError> {
let is_new = !database_path.try_exists()?;
let database = if is_new {
Database::create(database_path)?
} else {
Database::open(database_path)?
};
let store = Self {
database,
_domain_lock: domain_lock,
};
if is_new {
store.initialize(expected_domain)?;
} else {
store.validate_metadata()?;
if store.snapshot()?.domain != *expected_domain {
return Err(DomainStoreError::DomainMismatch);
}
}
Ok(store)
}
/// Reads and validates every typed row as one consistent redb snapshot.
///
/// # Errors
///
/// Refuses database, table, codec, key, duplicate, or relationship failures.
pub fn snapshot(&self) -> Result<DomainStore, DomainStoreError> {
self.validate_metadata()?;
let read = self.database.begin_read()?;
let domain = {
let table = read.open_table(DOMAINS)?;
let value = table
.get(SINGLETON_KEY)?
.ok_or(DomainStoreError::MissingRow("Domain"))?;
decode(value.value())?
};
let generations = read_generations(&read.open_table(GENERATIONS)?)?;
let participants = read_participants(&read.open_table(PARTICIPANTS)?)?;
let sessions = read_sessions(&read.open_table(SESSIONS)?)?;
let session_terminals = read_session_terminals(&read.open_table(SESSION_TERMINALS)?)?;
let mailboxes = read_mailboxes(&read.open_table(MAILBOXES)?)?;
let snapshot = DomainStore {
metadata: DomainStoreMetadata::v1(),
domain,
generations,
participants,
sessions,
session_terminals,
mailboxes,
};
validate_snapshot(&snapshot)?;
Ok(snapshot)
}
/// Applies the complete mutation batch in one immediate-durability redb transaction.
///
/// # Errors
///
/// Refuses invalid transitions, generation mismatches, malformed relationships, or storage
/// failures without committing any member of the batch.
pub fn apply(&self, mutations: &[DomainStoreMutation]) -> Result<(), DomainStoreError> {
if mutations.is_empty() {
return Ok(());
}
let mut snapshot = self.snapshot()?;
for mutation in mutations {
apply_mutation(&mut snapshot, mutation)?;
}
validate_snapshot(&snapshot)?;
self.write_snapshot(&snapshot)
}
fn initialize(&self, domain: &Domain) -> Result<(), DomainStoreError> {
let snapshot = DomainStore {
metadata: DomainStoreMetadata::v1(),
domain: domain.clone(),
generations: Vec::new(),
participants: Vec::new(),
sessions: Vec::new(),
session_terminals: Vec::new(),
mailboxes: Vec::new(),
};
self.write_snapshot(&snapshot)
}
fn validate_metadata(&self) -> Result<(), DomainStoreError> {
let read = self.database.begin_read()?;
let table = read.open_table(METADATA).map_err(|error| match error {
TableError::TableDoesNotExist(_) => DomainStoreError::MissingMetadata,
other => DomainStoreError::Table(other),
})?;
let value = table
.get(SINGLETON_KEY)?
.ok_or(DomainStoreError::MissingMetadata)?;
let _: DomainStoreMetadata = decode(value.value())?;
Ok(())
}
fn write_snapshot(&self, snapshot: &DomainStore) -> Result<(), DomainStoreError> {
let write = self.database.begin_write()?;
{
let mut table = write.open_table(METADATA)?;
let encoded = encode(&snapshot.metadata)?;
table.insert(SINGLETON_KEY, encoded.as_slice())?;
}
{
let mut table = write.open_table(DOMAINS)?;
let encoded = encode(&snapshot.domain)?;
table.insert(SINGLETON_KEY, encoded.as_slice())?;
}
{
let mut table = write.open_table(GENERATIONS)?;
for record in &snapshot.generations {
let encoded = encode(record)?;
table.insert(record.generation_id.get(), encoded.as_slice())?;
}
}
{
let mut table = write.open_table(PARTICIPANTS)?;
for record in &snapshot.participants {
let encoded = encode(record)?;
table.insert(&participant_key(record), encoded.as_slice())?;
}
}
{
let mut table = write.open_table(SESSIONS)?;
for record in &snapshot.sessions {
let encoded = encode(record)?;
table.insert(
&session_key(record.generation_id, record.session_id),
encoded.as_slice(),
)?;
}
}
{
let mut table = write.open_table(SESSION_TERMINALS)?;
for record in &snapshot.session_terminals {
let encoded = encode(record)?;
table.insert(
&session_key(record.generation_id(), record.session_id()),
encoded.as_slice(),
)?;
}
}
{
let mut table = write.open_table(MAILBOXES)?;
for record in &snapshot.mailboxes {
let encoded = encode(record)?;
table.insert(record.generation_id.get(), encoded.as_slice())?;
}
}
write.commit()?;
Ok(())
}
}
fn read_generations(
table: &redb::ReadOnlyTable<u64, &[u8]>,
) -> Result<Vec<Generation>, DomainStoreError> {
let mut records = Vec::new();
for entry in table.iter()? {
let (key, value) = entry?;
let record: Generation = decode(value.value())?;
if key.value() != record.generation_id.get() {
return Err(DomainStoreError::InvalidRow("Generation key"));
}
records.push(record);
}
Ok(records)
}
fn read_participants(
table: &redb::ReadOnlyTable<&[u8; 9], &[u8]>,
) -> Result<Vec<Participant>, DomainStoreError> {
let mut records = Vec::new();
for entry in table.iter()? {
let (key, value) = entry?;
let record: Participant = decode(value.value())?;
if key.value() != &participant_key(&record) {
return Err(DomainStoreError::InvalidRow("Participant key"));
}
records.push(record);
}
Ok(records)
}
fn read_sessions(
table: &redb::ReadOnlyTable<&[u8; 40], &[u8]>,
) -> Result<Vec<Session>, DomainStoreError> {
let mut records = Vec::new();
for entry in table.iter()? {
let (key, value) = entry?;
let record: Session = decode(value.value())?;
if key.value() != &session_key(record.generation_id, record.session_id) {
return Err(DomainStoreError::InvalidRow("Session key"));
}
records.push(record);
}
Ok(records)
}
fn read_session_terminals(
table: &redb::ReadOnlyTable<&[u8; 40], &[u8]>,
) -> Result<Vec<SessionTerminal>, DomainStoreError> {
let mut records = Vec::new();
for entry in table.iter()? {
let (key, value) = entry?;
let record: SessionTerminal = decode(value.value())?;
if key.value() != &session_key(record.generation_id(), record.session_id()) {
return Err(DomainStoreError::InvalidRow("Session Terminal key"));
}
records.push(record);
}
Ok(records)
}
fn read_mailboxes(
table: &redb::ReadOnlyTable<u64, &[u8]>,
) -> Result<Vec<Mailbox>, DomainStoreError> {
let mut records = Vec::new();
for entry in table.iter()? {
let (key, value) = entry?;
let record: Mailbox = decode(value.value())?;
if key.value() != record.generation_id.get() {
return Err(DomainStoreError::InvalidRow("Mailbox key"));
}
records.push(record);
}
Ok(records)
}
fn apply_mutation(
snapshot: &mut DomainStore,
mutation: &DomainStoreMutation,
) -> Result<(), DomainStoreError> {
match mutation {
DomainStoreMutation::PutGeneration(next) => put_generation(snapshot, next.clone()),
DomainStoreMutation::PutParticipant(next) => put_participant(snapshot, next.clone(), false),
DomainStoreMutation::CleanParticipantAfterAbsence(next) => {
put_participant(snapshot, next.clone(), true)
}
DomainStoreMutation::PutSession(next) => put_session(snapshot, next.clone()),
DomainStoreMutation::PutMailbox(next) => put_mailbox(snapshot, next.clone(), false),
DomainStoreMutation::CleanMailboxAfterAbsence(next) => {
put_mailbox(snapshot, next.clone(), true)
}
DomainStoreMutation::PutSessionTerminal(next) => {
put_session_terminal(snapshot, next.clone(), false)
}
DomainStoreMutation::CleanSessionTerminalAfterAbsence(next) => {
put_session_terminal(snapshot, next.clone(), true)
}
}
}
fn put_generation(snapshot: &mut DomainStore, next: Generation) -> Result<(), DomainStoreError> {
if next.domain_id != snapshot.domain.domain_id {
return Err(DomainStoreError::DomainMismatch);
}
if let Some(current) = snapshot
.generations
.iter_mut()
.find(|record| record.generation_id == next.generation_id)
{
validate_generation_transition(current, &next)?;
*current = next;
} else {
if next.durable_decision != DurableDecision::None {
return Err(DomainStoreError::InvalidRow("initial Generation"));
}
snapshot.generations.push(next);
}
Ok(())
}
fn put_participant(
snapshot: &mut DomainStore,
next: Participant,
absence_proven: bool,
) -> Result<(), DomainStoreError> {
require_generation(snapshot, next.generation_id())?;
if let Some(current) = snapshot.participants.iter_mut().find(|record| {
record.generation_id() == next.generation_id() && record.role() == next.role()
}) {
if absence_proven {
current.validate_transition_to_clean_after_absence(&next)?;
} else {
current.validate_transition_to(&next)?;
}
*current = next;
} else {
if absence_proven || next.state() != ParticipantState::Intent || next.ownership().is_some()
{
return Err(DomainStoreError::InvalidRow("initial Participant"));
}
snapshot.participants.push(next);
}
Ok(())
}
fn put_session(snapshot: &mut DomainStore, next: Session) -> Result<(), DomainStoreError> {
require_generation(snapshot, next.generation_id)?;
if let Some(current) = snapshot.sessions.iter_mut().find(|record| {
record.generation_id == next.generation_id && record.session_id == next.session_id
}) {
validate_session_transition(current, &next)?;
*current = next;
} else {
if !matches!(
next.session_phase,
SessionPhase::New | SessionPhase::Waiting | SessionPhase::Registering
) {
return Err(DomainStoreError::InvalidRow("initial Session"));
}
snapshot.sessions.push(next);
}
Ok(())
}
fn put_mailbox(
snapshot: &mut DomainStore,
next: Mailbox,
absence_proven: bool,
) -> Result<(), DomainStoreError> {
require_generation(snapshot, next.generation_id)?;
if let Some(current) = snapshot
.mailboxes
.iter_mut()
.find(|record| record.generation_id == next.generation_id)
{
validate_mailbox_transition(current, &next, absence_proven)?;
*current = next;
} else {
if absence_proven || next.state != ParticipantState::Intent {
return Err(DomainStoreError::InvalidRow("initial Mailbox"));
}
snapshot.mailboxes.push(next);
}
Ok(())
}
fn put_session_terminal(
snapshot: &mut DomainStore,
next: SessionTerminal,
absence_proven: bool,
) -> Result<(), DomainStoreError> {
require_session(snapshot, next.generation_id(), next.session_id())?;
if let Some(current) = snapshot.session_terminals.iter_mut().find(|record| {
record.generation_id() == next.generation_id() && record.session_id() == next.session_id()
}) {
if absence_proven {
current.validate_transition_to_clean_after_absence(&next)?;
} else {
current.validate_transition_to(&next)?;
}
*current = next;
} else {
if absence_proven
|| next.state() != SessionTerminalState::Intent
|| next.container_id().is_some()
|| !next.host_coordinator().is_unassigned()
|| next.pty_master().is_some()
|| !next.exec_client().is_unassigned()
|| next.guest_process().is_some()
{
return Err(DomainStoreError::InvalidRow("initial Session Terminal"));
}
snapshot.session_terminals.push(next);
}
Ok(())
}
fn validate_generation_transition(
current: &Generation,
next: &Generation,
) -> Result<(), DomainStoreError> {
if current.domain_id != next.domain_id
|| current.generation_id != next.generation_id
|| current.configuration_fingerprint != next.configuration_fingerprint
|| current.coordinator_id != next.coordinator_id
|| current.network_sidecar_id != next.network_sidecar_id
|| current.sandbox_id != next.sandbox_id
{
return Err(DomainStoreError::InvalidRow("Generation identity"));
}
let valid = current.durable_decision == next.durable_decision
|| matches!(
(current.durable_decision, next.durable_decision),
(
DurableDecision::None,
DurableDecision::Preparing | DurableDecision::Clean
) | (
DurableDecision::Preparing,
DurableDecision::Published | DurableDecision::Clean
) | (DurableDecision::Published, DurableDecision::Clean)
);
if valid {
Ok(())
} else {
Err(DomainStoreError::InvalidRow("Generation transition"))
}
}
fn validate_session_transition(current: &Session, next: &Session) -> Result<(), DomainStoreError> {
if current.generation_id != next.generation_id
|| current.session_id != next.session_id
|| current.cli_id != next.cli_id
|| current.session_anchor_id != next.session_anchor_id
{
return Err(DomainStoreError::InvalidRow("Session identity"));
}
let valid = current.session_phase == next.session_phase
|| matches!(
(current.session_phase, next.session_phase),
(
SessionPhase::New,
SessionPhase::Waiting | SessionPhase::Registering | SessionPhase::Failed
) | (
SessionPhase::Waiting,
SessionPhase::Registering | SessionPhase::Closed | SessionPhase::Failed
) | (
SessionPhase::Registering | SessionPhase::HostExecuting,
SessionPhase::Attached | SessionPhase::Closed | SessionPhase::Failed
) | (
SessionPhase::Attached,
SessionPhase::Prompting
| SessionPhase::HostExecuting
| SessionPhase::Closed
| SessionPhase::Failed
) | (
SessionPhase::Prompting,
SessionPhase::Attached
| SessionPhase::HostExecuting
| SessionPhase::Closed
| SessionPhase::Failed
)
);
if valid {
Ok(())
} else {
Err(DomainStoreError::InvalidRow("Session transition"))
}
}
fn validate_mailbox_transition(
current: &Mailbox,
next: &Mailbox,
absence_proven: bool,
) -> Result<(), DomainStoreError> {
if current.generation_id != next.generation_id
|| current.identity_path != next.identity_path
|| current.inbox_path != next.inbox_path
|| current.outbox_path != next.outbox_path
{
return Err(DomainStoreError::InvalidRow("Mailbox identity"));
}
if absence_proven {
return if matches!(
current.state,
ParticipantState::Intent | ParticipantState::ReleaseIntent
) && next.state == ParticipantState::Clean
{
Ok(())
} else {
Err(DomainStoreError::InvalidRow("Mailbox cleanup"))
};
}
let valid = current.state == next.state
|| matches!(
(current.state, next.state),
(
ParticipantState::Intent,
ParticipantState::LeaseHeld
| ParticipantState::Prepared
| ParticipantState::Committed
| ParticipantState::ReleaseIntent
) | (
ParticipantState::LeaseHeld
| ParticipantState::Prepared
| ParticipantState::Committed,
ParticipantState::ReleaseIntent
)
);
if valid {
Ok(())
} else {
Err(DomainStoreError::InvalidRow("Mailbox transition"))
}
}
fn validate_snapshot(snapshot: &DomainStore) -> Result<(), DomainStoreError> {
for (index, generation) in snapshot.generations.iter().enumerate() {
if generation.domain_id != snapshot.domain.domain_id {
return Err(DomainStoreError::DomainMismatch);
}
if snapshot.generations[..index]
.iter()
.any(|other| other.generation_id == generation.generation_id)
{
return Err(DomainStoreError::DuplicateRow("Generation"));
}
}
for (index, participant) in snapshot.participants.iter().enumerate() {
require_generation(snapshot, participant.generation_id())?;
if snapshot.participants[..index].iter().any(|other| {
other.generation_id() == participant.generation_id()
&& other.role() == participant.role()
}) {
return Err(DomainStoreError::DuplicateRow("Participant"));
}
}
for (index, session) in snapshot.sessions.iter().enumerate() {
require_generation(snapshot, session.generation_id)?;
if snapshot.sessions[..index].iter().any(|other| {
other.generation_id == session.generation_id && other.session_id == session.session_id
}) {
return Err(DomainStoreError::DuplicateRow("Session"));
}
}
for (index, terminal) in snapshot.session_terminals.iter().enumerate() {
require_session(snapshot, terminal.generation_id(), terminal.session_id())?;
if snapshot.session_terminals[..index].iter().any(|other| {
other.generation_id() == terminal.generation_id()
&& other.session_id() == terminal.session_id()
}) {
return Err(DomainStoreError::DuplicateRow("Session Terminal"));
}
}
for (index, mailbox) in snapshot.mailboxes.iter().enumerate() {
require_generation(snapshot, mailbox.generation_id)?;
if snapshot.mailboxes[..index]
.iter()
.any(|other| other.generation_id == mailbox.generation_id)
{
return Err(DomainStoreError::DuplicateRow("Mailbox"));
}
}
Ok(())
}
fn require_generation(
snapshot: &DomainStore,
generation_id: GenerationId,
) -> Result<(), DomainStoreError> {
if snapshot
.generations
.iter()
.any(|record| record.generation_id == generation_id)
{
Ok(())
} else {
Err(DomainStoreError::GenerationMismatch)
}
}
fn require_session(
snapshot: &DomainStore,
generation_id: GenerationId,
session_id: SessionId,
) -> Result<(), DomainStoreError> {
require_generation(snapshot, generation_id)?;
if snapshot
.sessions
.iter()
.any(|record| record.generation_id == generation_id && record.session_id == session_id)
{
Ok(())
} else {
Err(DomainStoreError::MissingRow("Session"))
}
}
fn participant_key(record: &Participant) -> [u8; 9] {
let mut key = [0; 9];
key[..8].copy_from_slice(&record.generation_id().get().to_be_bytes());
key[8] = role_key(record.role());
key
}
fn role_key(role: ParticipantRole) -> u8 {
match role {
ParticipantRole::PreparedLower => 0,
ParticipantRole::StateAnchor => 1,
ParticipantRole::GitRouting => 2,
ParticipantRole::Network => 3,
ParticipantRole::Helper => 4,
ParticipantRole::Sandbox => 5,
ParticipantRole::Publication => 6,
}
}
fn session_key(generation_id: GenerationId, session_id: SessionId) -> [u8; 40] {
let mut key = [0; 40];
key[..8].copy_from_slice(&generation_id.get().to_be_bytes());
key[8..].copy_from_slice(session_id.as_bytes());
key
}
#[cfg(test)]
mod tests;

View file

@ -0,0 +1,374 @@
use std::fs::{File, OpenOptions};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use lazaret_core::codec::encode;
use lazaret_core::identity::{
ClientId, ConfigurationFingerprint, CoordinatorId, DomainId, GenerationId, HostPath,
NetworkSidecarId, SandboxId, SessionAnchorId, SessionId,
};
use lazaret_core::records::{
DurableDecision, ParticipantOwnership, PersistedHostProcessOwnership, PublicationOwnership,
SessionTerminalFields,
};
use lazaret_linux::filesystem::try_exclusive_flock;
use redb::{ReadableDatabase, TableDefinition, TableHandle};
use serde::Serialize;
use super::*;
static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0);
const FOREIGN: TableDefinition<u8, u8> = TableDefinition::new("foreign");
struct TestDirectory(PathBuf);
impl TestDirectory {
fn new(label: &str) -> Self {
let sequence = NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"lazaret-domain-store-{label}-{}-{sequence}",
std::process::id()
));
std::fs::create_dir(&path).unwrap();
Self(path)
}
fn path(&self, name: &str) -> PathBuf {
self.0.join(name)
}
}
impl Drop for TestDirectory {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.0).unwrap();
}
}
fn bytes(value: u8) -> [u8; 32] {
[value; 32]
}
fn domain() -> Domain {
Domain {
domain_id: DomainId::new(bytes(1)),
profile_name: "trusted".into(),
canonical_workspace: HostPath::new(b"/workspace/project".to_vec()),
}
}
fn generation(decision: DurableDecision) -> Generation {
Generation {
domain_id: domain().domain_id,
generation_id: GenerationId::new(7),
configuration_fingerprint: ConfigurationFingerprint::new(bytes(2)),
coordinator_id: CoordinatorId::new(bytes(3)),
network_sidecar_id: NetworkSidecarId::new(bytes(4)),
sandbox_id: SandboxId::new(bytes(5)),
durable_decision: decision,
}
}
fn session(phase: SessionPhase) -> Session {
Session {
generation_id: GenerationId::new(7),
session_id: SessionId::new(bytes(6)),
cli_id: ClientId::new(bytes(7)),
session_anchor_id: SessionAnchorId::new(bytes(8)),
session_phase: phase,
}
}
fn mailbox(state: ParticipantState) -> Mailbox {
Mailbox {
generation_id: GenerationId::new(7),
identity_path: HostPath::new(b"identity".to_vec()),
inbox_path: HostPath::new(b"inbox".to_vec()),
outbox_path: HostPath::new(b"outbox".to_vec()),
state,
}
}
fn bare_terminal(state: SessionTerminalState) -> SessionTerminal {
SessionTerminal::snapshot(SessionTerminalFields {
generation_id: GenerationId::new(7),
session_id: SessionId::new(bytes(6)),
state,
container_id: None,
host_coordinator: PersistedHostProcessOwnership::unassigned(),
pty_master: None,
exec_client: PersistedHostProcessOwnership::unassigned(),
guest_process: None,
})
.unwrap()
}
fn publication_intent() -> Participant {
Participant::bare_intent(GenerationId::new(7), ParticipantRole::Publication)
}
fn publication_partial() -> Participant {
Participant::snapshot(
GenerationId::new(7),
ParticipantRole::Publication,
ParticipantState::Intent,
Some(ParticipantOwnership::Publication(PublicationOwnership {
socket_path: Some(HostPath::new(b"control.sock".to_vec())),
identity_path: None,
})),
)
.unwrap()
}
fn lock_file(path: &Path) -> File {
OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)
.unwrap()
}
fn open_store(directory: &TestDirectory) -> HostCoordinatorDomainStore {
let lock = try_exclusive_flock(lock_file(&directory.path("domain.lock"))).unwrap();
HostCoordinatorDomainStore::open(lock, &directory.path("domain.redb"), &domain()).unwrap()
}
#[test]
fn transaction_reopen_and_interrupted_write_preserve_complete_documents() {
let directory = TestDirectory::new("transaction");
let store = open_store(&directory);
store
.apply(&[
DomainStoreMutation::PutGeneration(generation(DurableDecision::None)),
DomainStoreMutation::PutParticipant(publication_intent()),
DomainStoreMutation::PutSession(session(SessionPhase::New)),
DomainStoreMutation::PutMailbox(mailbox(ParticipantState::Intent)),
DomainStoreMutation::PutSessionTerminal(bare_terminal(SessionTerminalState::Intent)),
])
.unwrap();
let refused = store.apply(&[
DomainStoreMutation::PutGeneration(generation(DurableDecision::Preparing)),
DomainStoreMutation::PutSession(session(SessionPhase::Attached)),
]);
assert!(matches!(
refused,
Err(DomainStoreError::InvalidRow("Session transition"))
));
assert_eq!(
store.snapshot().unwrap().generations[0].durable_decision,
DurableDecision::None
);
drop(store);
let database = Database::open(directory.path("domain.redb")).unwrap();
let write = database.begin_write().unwrap();
{
let mut table = write.open_table(GENERATIONS).unwrap();
let encoded = encode(&generation(DurableDecision::Preparing)).unwrap();
table.insert(7, encoded.as_slice()).unwrap();
}
drop(write);
drop(database);
let reopened = open_store(&directory);
let snapshot = reopened.snapshot().unwrap();
assert_eq!(
snapshot.generations[0].durable_decision,
DurableDecision::None
);
assert_eq!(snapshot.participants.len(), 1);
assert_eq!(snapshot.sessions.len(), 1);
assert_eq!(snapshot.mailboxes.len(), 1);
assert_eq!(snapshot.session_terminals.len(), 1);
}
#[test]
fn opener_retains_the_domain_lock_for_its_complete_lifetime() {
let directory = TestDirectory::new("opener");
let store = open_store(&directory);
assert!(try_exclusive_flock(lock_file(&directory.path("domain.lock"))).is_err());
assert!(matches!(
Database::open(directory.path("domain.redb")),
Err(DatabaseError::DatabaseAlreadyOpen)
));
drop(store);
assert!(try_exclusive_flock(lock_file(&directory.path("domain.lock"))).is_ok());
}
#[test]
fn metadata_and_typed_row_validation_fail_closed() {
#[derive(Serialize)]
struct RawMetadata<'a> {
schema_id: &'a str,
schema_version: u32,
}
let directory = TestDirectory::new("metadata");
drop(open_store(&directory));
let database = Database::open(directory.path("domain.redb")).unwrap();
let write = database.begin_write().unwrap();
{
let mut table = write.open_table(METADATA).unwrap();
let encoded = encode(&RawMetadata {
schema_id: "lazaret-domain-store-v1",
schema_version: 2,
})
.unwrap();
table.insert(SINGLETON_KEY, encoded.as_slice()).unwrap();
}
write.commit().unwrap();
drop(database);
let lock = try_exclusive_flock(lock_file(&directory.path("domain.lock"))).unwrap();
assert!(matches!(
HostCoordinatorDomainStore::open(lock, &directory.path("domain.redb"), &domain()),
Err(DomainStoreError::Codec(_))
));
let row_directory = TestDirectory::new("row-key");
let store = open_store(&row_directory);
store
.apply(&[DomainStoreMutation::PutGeneration(generation(
DurableDecision::None,
))])
.unwrap();
drop(store);
let database = Database::open(row_directory.path("domain.redb")).unwrap();
let write = database.begin_write().unwrap();
{
let mut table = write.open_table(GENERATIONS).unwrap();
let encoded = encode(&generation(DurableDecision::None)).unwrap();
table.insert(8, encoded.as_slice()).unwrap();
}
write.commit().unwrap();
drop(database);
let lock = try_exclusive_flock(lock_file(&row_directory.path("domain.lock"))).unwrap();
assert!(matches!(
HostCoordinatorDomainStore::open(lock, &row_directory.path("domain.redb"), &domain()),
Err(DomainStoreError::InvalidRow("Generation key"))
));
}
#[test]
fn generation_relationships_and_domain_identity_cannot_be_forged() {
let directory = TestDirectory::new("relationships");
let store = open_store(&directory);
assert!(matches!(
store.apply(&[DomainStoreMutation::PutParticipant(publication_intent())]),
Err(DomainStoreError::GenerationMismatch)
));
let mut wrong_domain = generation(DurableDecision::None);
wrong_domain.domain_id = DomainId::new(bytes(99));
assert!(matches!(
store.apply(&[DomainStoreMutation::PutGeneration(wrong_domain)]),
Err(DomainStoreError::DomainMismatch)
));
store
.apply(&[DomainStoreMutation::PutGeneration(generation(
DurableDecision::None,
))])
.unwrap();
drop(store);
let lock = try_exclusive_flock(lock_file(&directory.path("domain.lock"))).unwrap();
let mut other_domain = domain();
other_domain.profile_name = "other".into();
assert!(matches!(
HostCoordinatorDomainStore::open(lock, &directory.path("domain.redb"), &other_domain),
Err(DomainStoreError::DomainMismatch)
));
}
#[test]
fn lifecycle_updates_enforce_transition_and_absence_boundaries() {
let directory = TestDirectory::new("transitions");
let store = open_store(&directory);
store
.apply(&[
DomainStoreMutation::PutGeneration(generation(DurableDecision::None)),
DomainStoreMutation::PutParticipant(publication_intent()),
DomainStoreMutation::PutSession(session(SessionPhase::New)),
DomainStoreMutation::PutMailbox(mailbox(ParticipantState::Intent)),
DomainStoreMutation::PutSessionTerminal(bare_terminal(SessionTerminalState::Intent)),
])
.unwrap();
store
.apply(&[DomainStoreMutation::PutParticipant(publication_partial())])
.unwrap();
assert!(matches!(
store.apply(&[DomainStoreMutation::PutSession(session(
SessionPhase::Attached
))]),
Err(DomainStoreError::InvalidRow("Session transition"))
));
assert!(matches!(
store.apply(&[DomainStoreMutation::PutParticipant(
Participant::snapshot(
GenerationId::new(7),
ParticipantRole::Publication,
ParticipantState::Clean,
Some(ParticipantOwnership::Publication(PublicationOwnership {
socket_path: None,
identity_path: None,
})),
)
.unwrap(),
)]),
Err(DomainStoreError::Transition(
RecordTransitionError::AbsenceProofRequired
))
));
assert!(matches!(
store.apply(&[DomainStoreMutation::PutMailbox(mailbox(
ParticipantState::Clean
))]),
Err(DomainStoreError::InvalidRow("Mailbox transition"))
));
assert!(matches!(
store.apply(&[DomainStoreMutation::PutSessionTerminal(bare_terminal(
SessionTerminalState::Clean,
))]),
Err(DomainStoreError::Transition(
RecordTransitionError::AbsenceProofRequired
))
));
store
.apply(&[DomainStoreMutation::CleanSessionTerminalAfterAbsence(
bare_terminal(SessionTerminalState::Clean),
)])
.unwrap();
let snapshot = store.snapshot().unwrap();
assert_eq!(
snapshot.session_terminals[0].state(),
SessionTerminalState::Clean
);
}
#[test]
fn every_table_is_created_in_the_first_version_document() {
let directory = TestDirectory::new("tables");
drop(open_store(&directory));
let database = Database::open(directory.path("domain.redb")).unwrap();
let read = database.begin_read().unwrap();
for name in [
METADATA.name(),
DOMAINS.name(),
GENERATIONS.name(),
PARTICIPANTS.name(),
SESSIONS.name(),
SESSION_TERMINALS.name(),
MAILBOXES.name(),
] {
assert!(
read.list_tables()
.unwrap()
.any(|handle| handle.name() == name)
);
}
assert!(read.open_table(FOREIGN).is_err());
}

View file

@ -0,0 +1,3 @@
#![doc = "Host-side coordination authority for one Lazaret Domain."]
pub mod domain_store;

View file

@ -386,8 +386,8 @@ classDiagram
`durable_decision` and `session_phase` contain values defined by the application `durable_decision` and `session_phase` contain values defined by the application
protocol model. This schema records them without redefining either state machine. protocol model. This schema records them without redefining either state machine.
Each store carries its own schema identity and version. The current Domain Store Each store carries its own schema identity and version. The current Domain Store
identity is `lazaret-domain-store-v2` with version `2`. Domain Store v1 metadata identity is `lazaret-domain-store-v1` with version `1`. Unknown, unversioned,
dispatches only to legacy migration handling; it is never decoded as v2. The older, and newer metadata refuses before any row is interpreted. The
Storage Catalog remains `lazaret-storage-catalog-v1` with version `1`. Storage Catalog remains `lazaret-storage-catalog-v1` with version `1`.
### Host process identity and staged ownership ### Host process identity and staged ownership
@ -407,17 +407,9 @@ classDiagram
start_time_ticks start_time_ticks
} }
class LegacyUnverifiedProcessIdentityV1 {
<<serialized>>
host_pid
start_time_ticks
}
class HostProcessIdentity { class HostProcessIdentity {
<<serialized>> <<serialized>>
<<union>> identity
PidfsV1
LegacyUnverifiedV1
} }
class PersistedHostProcessOwnership { class PersistedHostProcessOwnership {
@ -467,8 +459,7 @@ classDiagram
} }
PidfsProcessIdentityV1 --> PidfsProcessAuthorityV1 : authority PidfsProcessIdentityV1 --> PidfsProcessAuthorityV1 : authority
HostProcessIdentity --> PidfsProcessIdentityV1 : PidfsV1 HostProcessIdentity --> PidfsProcessIdentityV1 : current authority
HostProcessIdentity --> LegacyUnverifiedProcessIdentityV1 : LegacyUnverifiedV1
PersistedHostProcessOwnership --> PidfsProcessIdentityV1 : Staged PersistedHostProcessOwnership --> PidfsProcessIdentityV1 : Staged
PersistedHostProcessOwnership --> HostProcessIdentity : Committed PersistedHostProcessOwnership --> HostProcessIdentity : Committed
StagedProcessOwnership --> ProcessOwnershipMessageVersion StagedProcessOwnership --> ProcessOwnershipMessageVersion
@ -490,11 +481,8 @@ Every persisted host-process edge uses `PersistedHostProcessOwnership`.
contains only a current pidfs identity. It is positive authority for exact contains only a current pidfs identity. It is positive authority for exact
same-handle cleanup and `PidfsRecoveryMatch`, including bootstrap recovery, but same-handle cleanup and `PidfsRecoveryMatch`, including bootstrap recovery, but
cannot satisfy readiness, publication, dependent-resource creation, or cannot satisfy readiness, publication, dependent-resource creation, or
adoption. `Committed` may contain a current pidfs identity or a adoption. `Committed` contains the current pidfs identity and is the only
legacy-unverified v1 PID/start-only value. Only committed current pidfs serviceable process-ownership phase.
ownership is serviceable; the legacy variant always refuses positive process
action. There is no observation-based conversion from legacy data to
`PidfsProcessIdentityV1`.
Participant `Intent` and `ReleaseIntent` may retain any process-slot phase. Participant `Intent` and `ReleaseIntent` may retain any process-slot phase.
Every active participant state and Session Terminal `Running` requires current Every active participant state and Session Terminal `Running` requires current

View file

@ -406,9 +406,8 @@ broker performs this sequence:
Every durable host-process slot is exactly `Unassigned`, Every durable host-process slot is exactly `Unassigned`,
`Staged(PidfsProcessIdentityV1)`, or `Committed(HostProcessIdentity)`. Only a `Staged(PidfsProcessIdentityV1)`, or `Committed(HostProcessIdentity)`. Only a
current pidfs identity may be staged. A committed legacy-unverified identity is current pidfs identity may be staged or committed. Current staged and committed
retained only for migration and refusal. Current staged and committed identity identity both authorize exact matched cleanup; only committed ownership may
both authorize exact matched cleanup; only committed current ownership may
satisfy an active role, readiness, publication, or adoption. satisfy an active role, readiness, publication, or adoption.
A role-bound Participant is first committed as bare `Intent` with no ownership A role-bound Participant is first committed as bare `Intent` with no ownership
@ -419,8 +418,8 @@ Within Intent, optional fields move only absent-to-present. A newly acquired
child slot moves only `Unassigned` to `Staged` to the exact same current child slot moves only `Unassigned` to `Staged` to the exact same current
identity in `Committed`. An already committed current owner or supervising identity in `Committed`. An already committed current owner or supervising
process may enter a newly created dependent-role snapshot directly, but that process may enter a newly created dependent-role snapshot directly, but that
recorded identity then remains immutable. Legacy data cannot use this recorded identity then remains immutable. Recorded evidence cannot be replaced
existing-process rule. Recorded evidence cannot be replaced or cleared. or cleared.
The direct existing-process edges are Network RS, Helper RS, Sandbox HC, and The direct existing-process edges are Network RS, Helper RS, Sandbox HC, and
Session Terminal HC. State Anchor daemon, Git-routing daemon, Helper process, Session Terminal HC. State Anchor daemon, Git-routing daemon, Helper process,
Sandbox RS, attached runsc, and Session Terminal exec client are acquired child Sandbox RS, attached runsc, and Session Terminal exec client are acquired child
@ -436,7 +435,7 @@ The active Participant pairs are `prepared_lower=LeaseHeld`,
role-required physical evidence and current committed pidfs ownership in every role-required physical evidence and current committed pidfs ownership in every
required process slot. `SessionTerminal=Running` likewise requires its required process slot. `SessionTerminal=Running` likewise requires its
container, PTY, guest identity, and current committed HC and exec-client slots. container, PTY, guest identity, and current committed HC and exec-client slots.
Staged, unassigned, or legacy process ownership cannot satisfy either boundary. Staged or unassigned process ownership cannot satisfy either boundary.
Network readiness evidence must also name the enclosing generation and match Network readiness evidence must also name the enclosing generation and match
its policy, fingerprint, namespace, and committed RS identity internally. its policy, fingerprint, namespace, and committed RS identity internally.
@ -474,13 +473,6 @@ timing assumptions.
7. Use that same matched pidfd for every action. An action-time `ESRCH` or 7. Use that same matched pidfd for every action. An action-time `ESRCH` or
observation failure refuses without numeric fallback. observation failure refuses without numeric fallback.
Domain Store v1 PID/start-only values dispatch to
`LegacyUnverifiedProcessIdentityV1`. They cannot be observed, converted, or
infallibly migrated into positive authority. They refuse live process action
until reboot, independently proven absence, or explicit operator resolution
allows the ordinary recovery state machine to reconcile the surrounding record.
Boot mismatch fences all old pidfs authority before PID lookup.
The contract accepts two residual assumptions: Linux boot-ID collision is The contract accepts two residual assumptions: Linux boot-ID collision is
operationally negligible but not mathematically impossible, and the Linux 6.9+ operationally negligible but not mathematically impossible, and the Linux 6.9+
boot-monotonic 64-bit pidfs inode counter does not wrap during one boot. Codecs boot-monotonic 64-bit pidfs inode counter does not wrap during one boot. Codecs
@ -802,8 +794,8 @@ document, never a half-applied generation decision.
Protocol durable decision vocabulary, multi-role intent, and mismatch refuse are Protocol durable decision vocabulary, multi-role intent, and mismatch refuse are
defined by the protocol model. The product store encodes that generation document defined by the protocol model. The product store encodes that generation document
in typed tables under a schema/db version (unknown newer versions fail closed in typed tables under a schema/db version (unknown versions fail closed before
until an explicit Host Coordinator migration), including generation identity, row interpretation), including generation identity,
minted runtime component IDs, and participant ownership material (for example minted runtime component IDs, and participant ownership material (for example
prepared-artifact ID, staged or committed pidfs process ownership, mount prepared-artifact ID, staged or committed pidfs process ownership, mount
identity, and network readiness fingerprint). identity, and network readiness fingerprint).
@ -880,11 +872,10 @@ bootstrap recovery may advance it to release intent. These rules make every
interrupted release conservative and prevent a mounted State Anchor from interrupted release conservative and prevent a mounted State Anchor from
becoming unleased. becoming unleased.
Store version acceptance occurs before physical resource inspection. The Store version acceptance occurs before row interpretation or physical resource
Domain Store v1-to-v2 migration preserves every PID/start-only value as inspection. The first product Domain Store identity is
legacy-unverified and never observes it into pidfs authority. Migration steps `lazaret-domain-store-v1` at version `1`; unversioned, unknown, older, and newer
create and validate destination rows in one redb transaction and update the metadata refuses without fallback.
version last. Future versions follow the same fail-closed ordering.
Files not represented in the upper layer follow the selected base image. Files, Files not represented in the upper layer follow the selected base image. Files,
directories, and whiteouts recorded in the upper layer remain authoritative when directories, and whiteouts recorded in the upper layer remain authoritative when