commit b8071bd9c7da8d1f40c9217aea4e4b92ad67b4b1
parent 9d9d291fdc29cfa0d4fe3278fa1440631c7611cf
Author: triesap <tyson@radroots.org>
Date: Sat, 22 Aug 2026 06:31:16 +0000
operations: bind passive TCP health and metrics
Diffstat:
11 files changed, 775 insertions(+), 27 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
@@ -241,6 +241,10 @@
- Optional TCP operations expose only cached `/livez`, `/readyz`, and
`/metrics`. They must not perform SQLite, provider, relay, credential, DNS,
or other active probes.
+- Keep the Myc TCP adapter sealed around the source-locked service-host server.
+ Do not add route registration, raw listener/server access, dependency-owned
+ public types, or a second independently observed lifecycle cache. Publish
+ only the fixed cached phase/readiness metric families and closed phase label.
- Keep logs as safe structured stderr output. Keep result data on stdout and
diagnostics on stderr. Use stable bounded public codes and messages, bounded
metric labels, and explicit redaction.
diff --git a/README b/README
@@ -94,6 +94,19 @@ status remains local to the permissioned Unix-admin surface. Status reason
codes use a closed twelve-value vocabulary; arbitrary strings cannot enter the
cached response.
+The optional TCP operations adapter consumes that same supervisor publication
+through a second passive bounded projection and delegates the transport to the
+source-locked service-host server. It exposes exactly HTTP/1.1 `GET /livez`,
+`GET /readyz`, and `GET /metrics`; every other method, path, or query is
+unrouteable. The two Prometheus families are the cached phase and cached
+readiness bit with only the closed phase label. Requests perform no SQLite,
+filesystem, provider, relay, credential, DNS, clock, or fresh-probe work.
+Detailed status, configuration, paths, identities, connection/audit data, and
+all mutations remain on the permissioned Unix-admin surface. The listener is
+disabled unless the validated configuration explicitly enables and binds it,
+and its parser floor, headers, response, concurrency, deadline, and idle bounds
+remain enforced by the shared server.
+
`parse_myc_config_v1` caps original bytes before decoding, checks the schema
header before closed contract admission, rejects duplicate, null, unknown, and
semantically inconsistent input, and returns an immutable document plus a
diff --git a/contracts/api_baselines/myc.txt b/contracts/api_baselines/myc.txt
@@ -485,6 +485,15 @@ pub enum myc::MycNip46WorkKind
pub myc::MycNip46WorkKind::Connect
pub myc::MycNip46WorkKind::Local
pub myc::MycNip46WorkKind::Provider
+pub enum myc::MycOperationsErrorKind
+pub myc::MycOperationsErrorKind::Accept
+pub myc::MycOperationsErrorKind::Bind
+pub myc::MycOperationsErrorKind::ConnectionTaskPanicked
+pub myc::MycOperationsErrorKind::Disabled
+pub myc::MycOperationsErrorKind::InvalidConfiguration
+pub myc::MycOperationsErrorKind::LocalAddress
+impl myc::MycOperationsErrorKind
+pub const fn myc::MycOperationsErrorKind::code(self) -> &'static str
pub enum myc::MycPersistenceHealthV1
pub myc::MycPersistenceHealthV1::ReadOnly
pub myc::MycPersistenceHealthV1::Ready
@@ -823,6 +832,12 @@ pub fn myc::MycAuthorizationChallengeUrl::as_str(&self) -> &str
pub fn myc::MycAuthorizationChallengeUrl::new(&str) -> core::result::Result<Self, myc::MycConnectionStateError>
impl core::fmt::Debug for myc::MycAuthorizationChallengeUrl
pub fn myc::MycAuthorizationChallengeUrl::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct myc::MycBoundOperationsServer
+impl myc::MycBoundOperationsServer
+pub fn myc::MycBoundOperationsServer::local_address(&self) -> core::net::socket_addr::SocketAddr
+pub async fn myc::MycBoundOperationsServer::serve(self, myc::MycOperationsCancellationToken) -> core::result::Result<(), myc::MycOperationsError>
+impl core::fmt::Debug for myc::MycBoundOperationsServer
+pub fn myc::MycBoundOperationsServer::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub struct myc::MycBoundedNip46Event
impl myc::MycBoundedNip46Event
pub fn myc::MycBoundedNip46Event::encrypted_content(&self) -> &str
@@ -1408,6 +1423,28 @@ impl myc::MycNormalizedConfigDigest
pub const fn myc::MycNormalizedConfigDigest::as_bytes(&self) -> &[u8; 32]
impl core::fmt::Debug for myc::MycNormalizedConfigDigest
pub fn myc::MycNormalizedConfigDigest::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct myc::MycOperationsCancellationToken
+impl myc::MycOperationsCancellationToken
+pub fn myc::MycOperationsCancellationToken::cancel(&self)
+pub fn myc::MycOperationsCancellationToken::is_cancelled(&self) -> bool
+pub fn myc::MycOperationsCancellationToken::new() -> Self
+impl core::fmt::Debug for myc::MycOperationsCancellationToken
+pub fn myc::MycOperationsCancellationToken::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct myc::MycOperationsError
+impl myc::MycOperationsError
+pub const fn myc::MycOperationsError::code(self) -> &'static str
+pub const fn myc::MycOperationsError::kind(self) -> myc::MycOperationsErrorKind
+impl core::error::Error for myc::MycOperationsError
+impl core::fmt::Debug for myc::MycOperationsError
+pub fn myc::MycOperationsError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+impl core::fmt::Display for myc::MycOperationsError
+pub fn myc::MycOperationsError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct myc::MycOperationsServer
+impl myc::MycOperationsServer
+pub async fn myc::MycOperationsServer::bind(self) -> core::result::Result<myc::MycBoundOperationsServer, myc::MycOperationsError>
+pub fn myc::MycOperationsServer::new(&myc::MycConfigDocumentV1, &myc::MycStatusReader) -> core::result::Result<Self, myc::MycOperationsError>
+impl core::fmt::Debug for myc::MycOperationsServer
+pub fn myc::MycOperationsServer::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub struct myc::MycOutboxStatusV1
impl myc::MycOutboxStatusV1
pub const fn myc::MycOutboxStatusV1::new(u64, u64, core::option::Option<myc::MycStatusUnixSeconds>) -> Self
@@ -1897,8 +1934,10 @@ pub const myc::MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES: usize
pub const myc::MYC_ENCRYPTED_IDENTITY_BACKUP_INCLUDED: bool
pub const myc::MYC_ENCRYPTED_IDENTITY_ENVELOPE_CONTRACT_VERSION: u32
pub const myc::MYC_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES: usize
+pub const myc::MYC_LIVEZ_PATH: &str
pub const myc::MYC_LOCAL_SIGNER_ENDPOINT: &str
pub const myc::MYC_LOCAL_SIGNER_TRANSPORT_CONTRACT_VERSION: u32
+pub const myc::MYC_METRICS_PATH: &str
pub const myc::MYC_MIGRATION_CATALOG_SHA256: [u8; 32]
pub const myc::MYC_NIP05_DOCUMENT_MAX_BYTES: usize
pub const myc::MYC_NIP05_PROJECTION_MAX_BYTES: usize
@@ -1907,6 +1946,7 @@ pub const myc::MYC_NIP46_EVENT_ID_MAX_BYTES: usize
pub const myc::MYC_NIP46_PUBLIC_KEY_MAX_BYTES: usize
pub const myc::MYC_NIP46_REQUEST_ID_MAX_UTF8_BYTES: usize
pub const myc::MYC_NIP46_SIGNATURE_MAX_BYTES: usize
+pub const myc::MYC_OPERATIONS_CONTRACT_VERSION: u32
pub const myc::MYC_OPERATOR_CONTRACT_VERSION: u32
pub const myc::MYC_PROVIDER_CONCURRENCY_MAX: u32
pub const myc::MYC_PROVIDER_CONTRACT_VERSION: u32
@@ -1920,6 +1960,7 @@ pub const myc::MYC_RATE_MAX_TRACKED_SUBJECTS: u32
pub const myc::MYC_RATE_RELAY_ID_MAX_BYTES: usize
pub const myc::MYC_RATE_RETENTION_MAX_MS: u64
pub const myc::MYC_RATE_WINDOW_MAX_MS: u64
+pub const myc::MYC_READYZ_PATH: &str
pub const myc::MYC_RUNTIME_FOUNDATION_CONTRACT_VERSION: u32
pub const myc::MYC_SIGNER_STATUS_CONTRACT_VERSION: u32
pub const myc::MYC_STATE_APPLICATION_ID: u32
diff --git a/contracts/services_hardening/config.v1.schema.json b/contracts/services_hardening/config.v1.schema.json
@@ -129,7 +129,7 @@
"additionalProperties": false,
"properties": {
"header_count": { "type": "integer", "minimum": 1, "maximum": 64, "default": 32, "x-radroots-default-source": "radroots_service_host" },
- "header_bytes": { "type": "integer", "minimum": 1, "maximum": 32768, "default": 16384, "x-radroots-default-source": "radroots_service_host" },
+ "header_bytes": { "type": "integer", "minimum": 8192, "maximum": 32768, "default": 16384, "x-radroots-default-source": "radroots_service_host" },
"response_body_utf8_bytes": { "type": "integer", "minimum": 1, "maximum": 1048576, "default": 1048576, "x-radroots-default-source": "radroots_service_host" },
"concurrent_connections": { "type": "integer", "minimum": 1, "maximum": 64, "default": 32, "x-radroots-default-source": "radroots_service_host" },
"request_deadline_ms": { "type": "integer", "minimum": 1, "maximum": 30000, "default": 15000, "x-radroots-default-source": "radroots_service_host" },
diff --git a/contracts/services_hardening/tcp_operations.v1.json b/contracts/services_hardening/tcp_operations.v1.json
@@ -0,0 +1,50 @@
+{
+ "schema": "radroots.myc.tcp-operations.v1",
+ "contract_version": 1,
+ "step": 155,
+ "transport": "http_1_1_over_optional_tcp",
+ "configuration": "validated_myc_config_v1_operations_block",
+ "route_registration_extension": false,
+ "routes": [
+ { "method": "GET", "path": "/livez", "source": "cached_supervisor_state" },
+ { "method": "GET", "path": "/readyz", "source": "cached_readiness_state" },
+ { "method": "GET", "path": "/metrics", "source": "cached_bounded_metrics_snapshot" }
+ ],
+ "metrics": {
+ "format": "prometheus_text_0_0_4",
+ "families": [
+ { "name": "radroots_myc_service_phase", "kind": "gauge", "labels": ["phase"] },
+ { "name": "radroots_myc_service_ready", "kind": "gauge", "labels": [] }
+ ],
+ "high_cardinality_labels": false,
+ "arbitrary_labels": false
+ },
+ "passive_read_forbidden_authority": [
+ "sqlite",
+ "filesystem",
+ "provider",
+ "relay",
+ "credential",
+ "dns",
+ "clock",
+ "fresh_probe"
+ ],
+ "local_only": [
+ "detailed_status",
+ "configuration",
+ "paths",
+ "identities",
+ "connections",
+ "audit",
+ "mutations"
+ ],
+ "deferrals": [
+ "daemon_composition",
+ "structured_logging_and_exit",
+ "integration_wave",
+ "rcld_promotion",
+ "nix",
+ "oci",
+ "terminal_consumer_convergence"
+ ]
+}
diff --git a/src/lib.rs b/src/lib.rs
@@ -15,6 +15,7 @@ mod nip46_wave_080_a;
#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
mod nip46_wave_080_b;
mod nip46_work;
+mod operations_v1;
mod provider_contract;
mod provider_credential;
mod provider_envelope;
@@ -82,6 +83,11 @@ pub use nip46_work::{
MycNip46WorkErrorKind, MycNip46WorkKind, MycPreparedNip46Request,
prepare_myc_nip46_decrypt_work, prepare_myc_nip46_request, prepare_myc_nip46_work,
};
+pub use operations_v1::{
+ MYC_LIVEZ_PATH, MYC_METRICS_PATH, MYC_OPERATIONS_CONTRACT_VERSION, MYC_READYZ_PATH,
+ MycBoundOperationsServer, MycOperationsCancellationToken, MycOperationsError,
+ MycOperationsErrorKind, MycOperationsServer,
+};
pub use provider_contract::{
MYC_PROVIDER_CONCURRENCY_MAX, MYC_PROVIDER_CONTRACT_VERSION, MYC_PROVIDER_INPUT_MAX_BYTES,
MYC_PROVIDER_OUTPUT_MAX_BYTES, MYC_PROVIDER_REQUEST_DEADLINE_MAX_MS,
diff --git a/src/operations_v1.rs b/src/operations_v1.rs
@@ -0,0 +1,267 @@
+//! Myc-owned adapter for the exact passive TCP operations surface.
+
+use core::{fmt, time::Duration};
+use std::{error::Error, net::SocketAddr};
+
+use radroots_service_host::{
+ BoundOperationsServer as HostBoundOperationsServer, CancellationToken as HostCancellationToken,
+ OperationsBindPolicy as HostOperationsBindPolicy,
+ OperationsListenAddress as HostOperationsListenAddress,
+ OperationsListenerConfig as HostOperationsListenerConfig,
+ OperationsServer as HostOperationsServer, OperationsServerError as HostOperationsServerError,
+ OperationsTransportLimitValues as HostOperationsTransportLimitValues,
+ OperationsTransportLimits as HostOperationsTransportLimits,
+};
+use serde_json::Value;
+
+use crate::{MycConfigDocumentV1, MycStatusReader};
+
+/// Exact Myc TCP operations contract version.
+pub const MYC_OPERATIONS_CONTRACT_VERSION: u32 = 1;
+
+/// Exact liveness route exposed by the optional TCP listener.
+pub const MYC_LIVEZ_PATH: &str = "/livez";
+
+/// Exact readiness route exposed by the optional TCP listener.
+pub const MYC_READYZ_PATH: &str = "/readyz";
+
+/// Exact bounded metrics route exposed by the optional TCP listener.
+pub const MYC_METRICS_PATH: &str = "/metrics";
+
+/// Cloneable cooperative cancellation owned by the Myc runtime supervisor.
+#[derive(Clone, Default)]
+pub struct MycOperationsCancellationToken {
+ inner: HostCancellationToken,
+}
+
+impl MycOperationsCancellationToken {
+ #[must_use]
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Requests cancellation. Repeated requests have no additional effect.
+ pub fn cancel(&self) {
+ self.inner.cancel();
+ }
+
+ #[must_use]
+ pub fn is_cancelled(&self) -> bool {
+ self.inner.is_cancelled()
+ }
+}
+
+impl fmt::Debug for MycOperationsCancellationToken {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("MycOperationsCancellationToken")
+ .field("cancelled", &self.is_cancelled())
+ .finish()
+ }
+}
+
+/// Unbound exact-route Myc TCP operations server.
+pub struct MycOperationsServer {
+ inner: HostOperationsServer,
+}
+
+impl MycOperationsServer {
+ /// Projects the already-validated Myc configuration and passive status cache.
+ pub fn new(
+ config: &MycConfigDocumentV1,
+ status: &MycStatusReader,
+ ) -> Result<Self, MycOperationsError> {
+ let listener = listener_config(config)?;
+ HostOperationsServer::new(listener, status.operations_cache())
+ .map(|inner| Self { inner })
+ .map_err(map_server_error)
+ }
+
+ /// Binds the exact configured address without starting admission.
+ pub async fn bind(self) -> Result<MycBoundOperationsServer, MycOperationsError> {
+ self.inner
+ .bind()
+ .await
+ .map(|inner| MycBoundOperationsServer { inner })
+ .map_err(map_server_error)
+ }
+}
+
+impl fmt::Debug for MycOperationsServer {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("MycOperationsServer([sealed])")
+ }
+}
+
+/// Successfully bound exact-route Myc TCP operations server.
+pub struct MycBoundOperationsServer {
+ inner: HostBoundOperationsServer,
+}
+
+impl MycBoundOperationsServer {
+ #[must_use]
+ pub fn local_address(&self) -> SocketAddr {
+ self.inner.local_address()
+ }
+
+ /// Serves until explicit supervisor cancellation, then drains owned work.
+ pub async fn serve(
+ self,
+ cancellation: MycOperationsCancellationToken,
+ ) -> Result<(), MycOperationsError> {
+ self.inner
+ .serve(cancellation.inner)
+ .await
+ .map_err(map_server_error)
+ }
+}
+
+impl fmt::Debug for MycBoundOperationsServer {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("MycBoundOperationsServer([sealed])")
+ }
+}
+
+/// Stable source-free Myc operations failure classification.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum MycOperationsErrorKind {
+ Disabled,
+ InvalidConfiguration,
+ Bind,
+ LocalAddress,
+ Accept,
+ ConnectionTaskPanicked,
+}
+
+impl MycOperationsErrorKind {
+ #[must_use]
+ pub const fn code(self) -> &'static str {
+ match self {
+ Self::Disabled => "operations_disabled",
+ Self::InvalidConfiguration => "operations_configuration_invalid",
+ Self::Bind => "operations_bind_failed",
+ Self::LocalAddress => "operations_local_address_failed",
+ Self::Accept => "operations_accept_failed",
+ Self::ConnectionTaskPanicked => "operations_connection_task_panicked",
+ }
+ }
+}
+
+/// One redacted source-free Myc operations failure.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub struct MycOperationsError {
+ kind: MycOperationsErrorKind,
+}
+
+impl MycOperationsError {
+ const fn new(kind: MycOperationsErrorKind) -> Self {
+ Self { kind }
+ }
+
+ #[must_use]
+ pub const fn kind(self) -> MycOperationsErrorKind {
+ self.kind
+ }
+
+ #[must_use]
+ pub const fn code(self) -> &'static str {
+ self.kind.code()
+ }
+}
+
+impl fmt::Debug for MycOperationsError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("MycOperationsError")
+ .field("kind", &self.kind)
+ .finish()
+ }
+}
+
+impl fmt::Display for MycOperationsError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("Myc TCP operations failed")
+ }
+}
+
+impl Error for MycOperationsError {}
+
+fn listener_config(
+ config: &MycConfigDocumentV1,
+) -> Result<HostOperationsListenerConfig, MycOperationsError> {
+ let operations = config
+ .normalized()
+ .pointer("/operations")
+ .ok_or_else(invalid_configuration)?;
+ if !boolean(operations, "/enabled")? {
+ return Err(MycOperationsError::new(MycOperationsErrorKind::Disabled));
+ }
+ let listen = string(operations, "/listen")?
+ .parse::<SocketAddr>()
+ .map_err(|_| invalid_configuration())?;
+ let listen = HostOperationsListenAddress::new(listen).map_err(|_| invalid_configuration())?;
+ let bind_policy = match string(operations, "/bind_policy")? {
+ "loopback_only" => HostOperationsBindPolicy::LoopbackOnly,
+ "public" => HostOperationsBindPolicy::Public,
+ _ => return Err(invalid_configuration()),
+ };
+ let values = HostOperationsTransportLimitValues {
+ header_count: unsigned_u32(operations, "/limits/header_count")?,
+ header_bytes: unsigned_u32(operations, "/limits/header_bytes")?,
+ response_body_utf8_bytes: unsigned_u32(operations, "/limits/response_body_utf8_bytes")?,
+ concurrent_connections: unsigned_u32(operations, "/limits/concurrent_connections")?,
+ request_deadline: Duration::from_millis(unsigned(
+ operations,
+ "/limits/request_deadline_ms",
+ )?),
+ idle_timeout: Duration::from_millis(unsigned(operations, "/limits/idle_timeout_ms")?),
+ };
+ let limits = HostOperationsTransportLimits::new(values).map_err(|_| invalid_configuration())?;
+ HostOperationsListenerConfig::enabled(listen, bind_policy, limits)
+ .map_err(|_| invalid_configuration())
+}
+
+fn boolean(value: &Value, pointer: &str) -> Result<bool, MycOperationsError> {
+ value
+ .pointer(pointer)
+ .and_then(Value::as_bool)
+ .ok_or_else(invalid_configuration)
+}
+
+fn string<'a>(value: &'a Value, pointer: &str) -> Result<&'a str, MycOperationsError> {
+ value
+ .pointer(pointer)
+ .and_then(Value::as_str)
+ .ok_or_else(invalid_configuration)
+}
+
+fn unsigned(value: &Value, pointer: &str) -> Result<u64, MycOperationsError> {
+ value
+ .pointer(pointer)
+ .and_then(Value::as_u64)
+ .ok_or_else(invalid_configuration)
+}
+
+fn unsigned_u32(value: &Value, pointer: &str) -> Result<u32, MycOperationsError> {
+ u32::try_from(unsigned(value, pointer)?).map_err(|_| invalid_configuration())
+}
+
+const fn invalid_configuration() -> MycOperationsError {
+ MycOperationsError::new(MycOperationsErrorKind::InvalidConfiguration)
+}
+
+const fn map_server_error(error: HostOperationsServerError) -> MycOperationsError {
+ let kind = match error {
+ HostOperationsServerError::Disabled => MycOperationsErrorKind::Disabled,
+ HostOperationsServerError::HeaderLimitBelowParserFloor => {
+ MycOperationsErrorKind::InvalidConfiguration
+ }
+ HostOperationsServerError::Bind { .. } => MycOperationsErrorKind::Bind,
+ HostOperationsServerError::LocalAddress { .. } => MycOperationsErrorKind::LocalAddress,
+ HostOperationsServerError::Accept { .. } => MycOperationsErrorKind::Accept,
+ HostOperationsServerError::ConnectionTaskPanicked => {
+ MycOperationsErrorKind::ConnectionTaskPanicked
+ }
+ };
+ MycOperationsError::new(kind)
+}
diff --git a/src/status_v1.rs b/src/status_v1.rs
@@ -4,16 +4,19 @@ use core::fmt;
use std::{error::Error, sync::Arc, time::Duration};
use radroots_service_host::{
- BuildInfo as HostBuildInfo, BuildInfoEnvironment as HostBuildInfoEnvironment,
- BuildMode as HostBuildMode, CachedServiceState, CachedServiceStatePublisher,
- CachedServiceStateReader, ConfigurationIdentity as HostConfigurationIdentity,
+ BoundedMetricsSnapshot, BuildInfo as HostBuildInfo,
+ BuildInfoEnvironment as HostBuildInfoEnvironment, BuildMode as HostBuildMode,
+ CachedServiceState, CachedServiceStatePublisher, CachedServiceStateReader, CommonMetricGroup,
+ ConfigurationIdentity as HostConfigurationIdentity,
ConfigurationSource as HostConfigurationSource, ContractVersions as HostContractVersions,
- InstanceId, IntegrityState as HostIntegrityState, PersistenceHealth as HostPersistenceHealth,
- PersistenceSummary as HostPersistenceSummary, Readiness as HostReadiness,
- ReasonCode as HostReasonCode, ReasonCodes as HostReasonCodes, ServiceId,
- ServiceOperationalState as HostServiceOperationalState, ServicePhase as HostServicePhase,
- ServiceStatus, ServiceStatusDetail, Sha256Digest as HostSha256Digest, StatusContractError,
- StatusEncodingError, StatusModelError, UptimeMillis as HostUptimeMillis, cached_service_state,
+ InstanceId, IntegrityState as HostIntegrityState, MetricDescriptor, MetricKind, MetricLabel,
+ MetricLabelKey, MetricName, MetricSample, MetricValue,
+ PersistenceHealth as HostPersistenceHealth, PersistenceSummary as HostPersistenceSummary,
+ Readiness as HostReadiness, ReasonCode as HostReasonCode, ReasonCodes as HostReasonCodes,
+ ServiceId, ServiceOperationalState as HostServiceOperationalState,
+ ServicePhase as HostServicePhase, ServiceStatus, ServiceStatusDetail,
+ Sha256Digest as HostSha256Digest, StatusContractError, StatusEncodingError, StatusModelError,
+ UptimeMillis as HostUptimeMillis, cached_service_state,
};
use serde::Serialize;
@@ -683,11 +686,9 @@ impl MycStatusObservationV1 {
}
}
- fn into_cached(
- self,
- instance: &InstanceId,
- ) -> Result<CachedServiceState<MycCachedStatus>, MycStatusError> {
+ fn into_cached(self, instance: &InstanceId) -> Result<PreparedMycStatus, MycStatusError> {
let operational = self.common.operational.clone();
+ let operations_metrics = bounded_operations_metrics(&operational)?;
let detail = MycStatusDetailV1 {
transport: self.provider.transport.clone(),
user: self.provider.user.clone(),
@@ -711,12 +712,15 @@ impl MycStatusObservationV1 {
)
.map_err(map_model_error)?;
let json = status.to_bounded_json().map_err(map_encoding_error)?;
- Ok(CachedServiceState::new(
- operational,
- MycCachedStatus {
- json: json.into_boxed_slice(),
- },
- ))
+ Ok(PreparedMycStatus {
+ detail: CachedServiceState::new(
+ operational.clone(),
+ MycCachedStatus {
+ json: json.into_boxed_slice(),
+ },
+ ),
+ operations: CachedServiceState::new(operational, operations_metrics),
+ })
}
}
@@ -730,6 +734,55 @@ struct MycCachedStatus {
json: Box<[u8]>,
}
+struct PreparedMycStatus {
+ detail: CachedServiceState<MycCachedStatus>,
+ operations: CachedServiceState<BoundedMetricsSnapshot>,
+}
+
+fn bounded_operations_metrics(
+ operational: &HostServiceOperationalState,
+) -> Result<BoundedMetricsSnapshot, MycStatusError> {
+ let phase_name = MetricName::new("radroots_myc_service_phase").map_err(map_metrics_error)?;
+ let ready_name = MetricName::new("radroots_myc_service_ready").map_err(map_metrics_error)?;
+ let descriptors = [
+ MetricDescriptor::new(
+ CommonMetricGroup::Phase,
+ phase_name.clone(),
+ "Current cached Myc service phase.",
+ MetricKind::Gauge,
+ [MetricLabelKey::Phase],
+ )
+ .map_err(map_metrics_error)?,
+ MetricDescriptor::new(
+ CommonMetricGroup::Phase,
+ ready_name.clone(),
+ "Current cached Myc readiness bit.",
+ MetricKind::Gauge,
+ [],
+ )
+ .map_err(map_metrics_error)?,
+ ];
+ let samples = [
+ MetricSample::new(
+ phase_name,
+ MetricValue::Gauge(1),
+ [MetricLabel::phase(operational.phase())],
+ )
+ .map_err(map_metrics_error)?,
+ MetricSample::new(
+ ready_name,
+ MetricValue::Gauge(i64::from(operational.readiness().is_ready())),
+ [],
+ )
+ .map_err(map_metrics_error)?,
+ ];
+ BoundedMetricsSnapshot::new(descriptors, samples).map_err(map_metrics_error)
+}
+
+fn map_metrics_error(_: radroots_service_host::MetricsContractError) -> MycStatusError {
+ MycStatusError::new(MycStatusErrorKind::InvalidModel)
+}
+
impl fmt::Debug for MycCachedStatus {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
@@ -747,13 +800,18 @@ impl fmt::Debug for MycCachedStatus {
pub struct MycStatusPublisher {
instance: InstanceId,
inner: CachedServiceStatePublisher<MycCachedStatus>,
+ operations: CachedServiceStatePublisher<BoundedMetricsSnapshot>,
}
impl MycStatusPublisher {
- /// Encodes and atomically publishes one already-observed status value.
+ /// Encodes one observation, publishes its passive operations projection,
+ /// and then atomically replaces the detailed-status snapshot.
pub fn publish(&mut self, next: MycStatusObservationV1) -> Result<(), MycStatusError> {
let next = next.into_cached(&self.instance)?;
- self.inner.publish(next).map_err(map_contract_error)
+ self.operations
+ .publish(next.operations)
+ .map_err(map_contract_error)?;
+ self.inner.publish(next.detail).map_err(map_contract_error)
}
/// Creates another passive reader without sharing publication authority.
@@ -761,6 +819,7 @@ impl MycStatusPublisher {
pub fn subscribe(&self) -> MycStatusReader {
MycStatusReader {
inner: self.inner.subscribe(),
+ operations: self.operations.subscribe(),
}
}
}
@@ -774,12 +833,14 @@ impl fmt::Debug for MycStatusPublisher {
/// Cloneable passive reader of the latest Myc lifecycle and detailed status.
pub struct MycStatusReader {
inner: CachedServiceStateReader<MycCachedStatus>,
+ operations: CachedServiceStateReader<BoundedMetricsSnapshot>,
}
impl Clone for MycStatusReader {
fn clone(&self) -> Self {
Self {
inner: self.inner.clone(),
+ operations: self.operations.clone(),
}
}
}
@@ -801,6 +862,10 @@ impl MycStatusReader {
.map(|inner| MycStatusSnapshot { inner })
.map_err(|_| MycStatusError::new(MycStatusErrorKind::PublisherDropped))
}
+
+ pub(crate) fn operations_cache(&self) -> CachedServiceStateReader<BoundedMetricsSnapshot> {
+ self.operations.clone()
+ }
}
impl fmt::Debug for MycStatusReader {
@@ -849,10 +914,18 @@ pub fn myc_status_cache(
initial: MycStatusObservationV1,
) -> Result<(MycStatusPublisher, MycStatusReader), MycStatusError> {
let initial = initial.into_cached(&instance)?;
- let (inner, reader) = cached_service_state(initial);
+ let (inner, reader) = cached_service_state(initial.detail);
+ let (operations, operations_reader) = cached_service_state(initial.operations);
Ok((
- MycStatusPublisher { instance, inner },
- MycStatusReader { inner: reader },
+ MycStatusPublisher {
+ instance,
+ inner,
+ operations,
+ },
+ MycStatusReader {
+ inner: reader,
+ operations: operations_reader,
+ },
))
}
diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs
@@ -17,6 +17,9 @@ const DELIVERY_RECOVERY: &str = include_str!("../src/state_recovery.rs");
const DOCTOR_V1: &str = include_str!("../src/doctor_v1.rs");
const STATUS_V1: &str = include_str!("../src/status_v1.rs");
const STATUS_CONTRACT: &str = include_str!("../contracts/services_hardening/status_cache.v1.json");
+const OPERATIONS_V1: &str = include_str!("../src/operations_v1.rs");
+const OPERATIONS_CONTRACT: &str =
+ include_str!("../contracts/services_hardening/tcp_operations.v1.json");
const DISCOVERY_STATE: &str = include_str!("../src/state_discovery.rs");
const NIP46_VERIFICATION_CONTRACT: &str =
include_str!("../contracts/services_hardening/nip46_verification.v1.json");
@@ -44,6 +47,7 @@ const SOURCES: &[&str] = &[
include_str!("../src/nip46_replay.rs"),
include_str!("../src/nip46_verification.rs"),
include_str!("../src/nip46_work.rs"),
+ include_str!("../src/operations_v1.rs"),
include_str!("../src/provider_contract.rs"),
include_str!("../src/provider_credential.rs"),
include_str!("../src/provider_envelope.rs"),
@@ -86,6 +90,7 @@ fn implementation_modules_are_private_and_rustdoc_uses_the_reviewed_readme() {
"nip46_work",
"nip46_wave_080_a",
"nip46_wave_080_b",
+ "operations_v1",
"provider_contract",
"provider_credential",
"provider_envelope",
@@ -148,6 +153,11 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() {
"pub struct myc::MycStatusCommonV1",
"pub struct myc::MycStatusObservationV1",
"pub fn myc::myc_status_cache",
+ "pub struct myc::MycOperationsServer",
+ "pub struct myc::MycBoundOperationsServer",
+ "pub struct myc::MycOperationsCancellationToken",
+ "pub struct myc::MycOperationsError",
+ "pub enum myc::MycOperationsErrorKind",
"pub struct myc::MycAdminRequestDocument",
"pub struct myc::MycAdminResponseDocument",
"pub enum myc::MycAdminMethod",
@@ -228,6 +238,7 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() {
"nip46_work",
"nip46_wave_080_a",
"nip46_wave_080_b",
+ "operations_v1",
"provider_contract",
"provider_credential",
"provider_envelope",
@@ -291,7 +302,8 @@ fn status_cache_is_passive_latest_value_and_dependency_neutral() {
"pub struct MycStatusSnapshot",
"pub fn myc_status_cache(",
"status.to_bounded_json()",
- "self.inner.publish(next)",
+ ".publish(next.operations)",
+ ".publish(next.detail)",
"self.inner.snapshot()",
"connection_counts: MycConnectionCountsV1",
"oldest_pending_at_utc: Option<MycStatusUnixSeconds>",
@@ -339,6 +351,50 @@ fn status_cache_is_passive_latest_value_and_dependency_neutral() {
}
#[test]
+fn step155_tcp_operations_are_exact_passive_and_dependency_neutral() {
+ let contract: serde_json::Value =
+ serde_json::from_str(OPERATIONS_CONTRACT).expect("Step 155 operations contract");
+ assert_eq!(contract["schema"], "radroots.myc.tcp-operations.v1");
+ assert_eq!(contract["contract_version"], 1);
+ assert_eq!(contract["step"], 155);
+ assert_eq!(contract["route_registration_extension"], false);
+ for required in [
+ "HostOperationsServer::new(listener, status.operations_cache())",
+ "MycOperationsCancellationToken",
+ "radroots_myc_service_phase",
+ "radroots_myc_service_ready",
+ "HostOperationsTransportLimits::new(values)",
+ "HeaderLimitBelowParserFloor",
+ ] {
+ assert!(
+ OPERATIONS_V1.contains(required) || STATUS_V1.contains(required),
+ "Step 155 implementation is missing `{required}`"
+ );
+ }
+ for forbidden in [
+ "sqlx::",
+ "std::fs::",
+ "tokio::spawn",
+ "spawn_blocking",
+ "SystemTime",
+ "provider.execute",
+ "relay.connect",
+ "credential",
+ "dns",
+ "route(",
+ "Router",
+ ] {
+ assert!(
+ !OPERATIONS_V1.contains(forbidden),
+ "Step 155 adapter gained forbidden authority `{forbidden}`"
+ );
+ }
+ assert!(README.contains("exactly HTTP/1.1 `GET /livez`"));
+ assert!(README.contains("Requests perform no SQLite"));
+ assert!(!PUBLIC_API.contains("radroots_service_host::"));
+}
+
+#[test]
fn doctor_boundary_is_closed_bounded_and_dependency_neutral() {
for required in [
"MYC_DOCTOR_CHECK_COUNT: usize = 13",
@@ -710,7 +766,7 @@ fn public_errors_remain_crate_owned_redacted_and_source_free() {
.lines()
.filter(|line| line.starts_with("pub struct myc::") && line.ends_with("Error"))
.count();
- assert_eq!(public_error_count, 29);
+ assert_eq!(public_error_count, 30);
assert!(PUBLIC_API.contains("pub struct myc::MycDoctorError"));
assert!(!PUBLIC_API.contains("pub struct myc::MycRuntimeFoundation {"));
assert!(!PUBLIC_API.contains("pub struct myc::MycStateHost {"));
diff --git a/tests/services_hardening_config_contract.rs b/tests/services_hardening_config_contract.rs
@@ -379,6 +379,10 @@ fn schema_identity_structure_and_machine_policy_are_exact() {
#[test]
fn lib_derived_limits_and_defaults_are_literal_frozen() {
let schema = schema();
+ assert_eq!(
+ schema["$defs"]["operations_limits"]["properties"]["header_bytes"]["minimum"],
+ 8_192
+ );
let exact = [
("/$defs/operations_limits/properties/header_count", 64, 32),
(
diff --git a/tests/services_hardening_operations.rs b/tests/services_hardening_operations.rs
@@ -0,0 +1,234 @@
+#![forbid(unsafe_code)]
+
+use std::error::Error;
+use std::net::{Ipv4Addr, SocketAddrV4, TcpListener};
+
+use myc::{
+ InstanceId, MYC_LIVEZ_PATH, MYC_METRICS_PATH, MYC_OPERATIONS_CONTRACT_VERSION, MYC_READYZ_PATH,
+ MycConfigProfile, MycConnectionCountsV1, MycIdentityHealthV1, MycIntegrityStateV1,
+ MycOperationsCancellationToken, MycOperationsErrorKind, MycOperationsServer, MycOutboxStatusV1,
+ MycPersistenceHealthV1, MycPersistenceStatusV1, MycProviderStatusV1, MycRelayTransportStatusV1,
+ MycServicePhase, MycStatusBuildInfoV1, MycStatusBuildMode, MycStatusCommonV1,
+ MycStatusConfigurationIdentityV1, MycStatusConfigurationSource, MycStatusObservationV1,
+ MycStatusReasonCodes, MycTransportHealthV1, myc_status_cache, parse_myc_config_v1,
+};
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::TcpStream;
+
+const CONTRACT: &str = include_str!("../contracts/services_hardening/tcp_operations.v1.json");
+const CONFIG: &str = include_str!("../contracts/services_hardening/config.v1.example.toml");
+const SERVICE_REVISION: &str = "0123456789abcdef0123456789abcdef01234567";
+const LIB_REVISION: &str = "89abcdef0123456789abcdef0123456789abcdef";
+
+fn build_info() -> MycStatusBuildInfoV1 {
+ MycStatusBuildInfoV1::new(
+ MycStatusBuildMode::Release,
+ Some("0.1.0"),
+ Some(SERVICE_REVISION),
+ Some(LIB_REVISION),
+ Some("1.97.1"),
+ Some("x86_64-unknown-linux-gnu"),
+ Some("service-host"),
+ )
+ .expect("build info")
+}
+
+fn identity(configured: bool, available: bool) -> MycIdentityHealthV1 {
+ MycIdentityHealthV1::new(configured, available, MycStatusReasonCodes::empty())
+ .expect("identity health")
+}
+
+fn observation(phase: MycServicePhase, ready: bool) -> MycStatusObservationV1 {
+ let configuration = MycStatusConfigurationIdentityV1::new(
+ "a".repeat(64),
+ MycStatusConfigurationSource::ExplicitConfig,
+ )
+ .expect("configuration");
+ let persistence = MycPersistenceStatusV1::new(
+ MycPersistenceHealthV1::Ready,
+ 9,
+ 42,
+ MycIntegrityStateV1::Verified,
+ MycStatusReasonCodes::empty(),
+ )
+ .expect("persistence");
+ let provider = MycProviderStatusV1::new(
+ identity(true, true),
+ identity(true, true),
+ identity(false, false),
+ MycStatusReasonCodes::empty(),
+ )
+ .expect("provider");
+ let transport = MycRelayTransportStatusV1::new(
+ MycTransportHealthV1::Ready,
+ true,
+ 2,
+ MycStatusReasonCodes::empty(),
+ )
+ .expect("transport");
+ MycStatusObservationV1::new(
+ MycStatusCommonV1::new(
+ phase,
+ ready,
+ MycStatusReasonCodes::empty(),
+ 1,
+ build_info(),
+ configuration,
+ persistence,
+ )
+ .expect("common status"),
+ provider,
+ transport,
+ MycConnectionCountsV1::default(),
+ MycOutboxStatusV1::new(0, 0, None),
+ )
+}
+
+fn available_port() -> u16 {
+ let listener =
+ TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)).expect("ephemeral listener");
+ listener.local_addr().expect("local address").port()
+}
+
+fn enabled_config(port: u16) -> String {
+ CONFIG.replacen(
+ "[operations]\nenabled = false",
+ &format!(
+ "[operations]\nenabled = true\nlisten = \"127.0.0.1:{port}\"\nbind_policy = \"loopback_only\"\n\n[operations.limits]\nheader_count = 16\nheader_bytes = 8192\nresponse_body_utf8_bytes = 4096\nconcurrent_connections = 4\nrequest_deadline_ms = 500\nidle_timeout_ms = 500"
+ ),
+ 1,
+ )
+}
+
+async fn raw_request(address: std::net::SocketAddr, request: &[u8]) -> Vec<u8> {
+ let mut stream = TcpStream::connect(address).await.expect("connect");
+ stream.write_all(request).await.expect("request write");
+ let mut response = Vec::new();
+ stream
+ .read_to_end(&mut response)
+ .await
+ .expect("response read");
+ response
+}
+
+fn response_text(response: &[u8]) -> &str {
+ std::str::from_utf8(response).expect("response UTF-8")
+}
+
+#[test]
+fn machine_contract_freezes_exact_routes_metrics_and_non_authority() {
+ let contract: serde_json::Value = serde_json::from_str(CONTRACT).expect("contract");
+ assert_eq!(contract["schema"], "radroots.myc.tcp-operations.v1");
+ assert_eq!(
+ contract["contract_version"],
+ MYC_OPERATIONS_CONTRACT_VERSION
+ );
+ assert_eq!(contract["step"], 155);
+ assert_eq!(
+ contract["routes"]
+ .as_array()
+ .expect("routes")
+ .iter()
+ .map(|route| route["path"].as_str().expect("route path"))
+ .collect::<Vec<_>>(),
+ [MYC_LIVEZ_PATH, MYC_READYZ_PATH, MYC_METRICS_PATH]
+ );
+ assert_eq!(contract["route_registration_extension"], false);
+ assert_eq!(contract["metrics"]["high_cardinality_labels"], false);
+ assert_eq!(contract["metrics"]["arbitrary_labels"], false);
+}
+
+#[tokio::test]
+async fn exact_tcp_routes_use_only_latest_cached_lifecycle_and_metrics() {
+ let config = parse_myc_config_v1(
+ enabled_config(available_port()).as_bytes(),
+ MycConfigProfile::Production,
+ )
+ .expect("enabled config");
+ let (mut publisher, reader) = myc_status_cache(
+ InstanceId::new("primary").expect("instance"),
+ observation(MycServicePhase::Ready, true),
+ )
+ .expect("status cache");
+ let detail_pointer = reader.snapshot().detailed_status_json().as_ptr();
+ let bound = MycOperationsServer::new(&config, &reader)
+ .expect("operations server")
+ .bind()
+ .await
+ .expect("bind");
+ let address = bound.local_address();
+ let cancellation = MycOperationsCancellationToken::new();
+ let task = tokio::spawn(bound.serve(cancellation.clone()));
+
+ let live = raw_request(address, b"GET /livez HTTP/1.1\r\nhost: localhost\r\n\r\n").await;
+ let ready = raw_request(address, b"GET /readyz HTTP/1.1\r\nhost: localhost\r\n\r\n").await;
+ let metrics = raw_request(address, b"GET /metrics HTTP/1.1\r\nhost: localhost\r\n\r\n").await;
+ assert!(response_text(&live).starts_with("HTTP/1.1 200 OK\r\n"));
+ assert!(response_text(&live).ends_with("live\n"));
+ assert!(response_text(&ready).ends_with("ready\n"));
+ assert!(response_text(&metrics).contains("# TYPE radroots_myc_service_phase gauge\n"));
+ assert!(response_text(&metrics).contains("radroots_myc_service_phase{phase=\"ready\"} 1\n"));
+ assert!(response_text(&metrics).contains("radroots_myc_service_ready 1\n"));
+
+ for request in [
+ &b"GET /status HTTP/1.1\r\nhost: localhost\r\n\r\n"[..],
+ &b"GET /readyz?probe=1 HTTP/1.1\r\nhost: localhost\r\n\r\n"[..],
+ &b"POST /metrics HTTP/1.1\r\nhost: localhost\r\ncontent-length: 0\r\n\r\n"[..],
+ &b"GET /v1/status HTTP/1.1\r\nhost: localhost\r\n\r\n"[..],
+ ] {
+ let rejected = raw_request(address, request).await;
+ assert!(response_text(&rejected).starts_with("HTTP/1.1 404 Not Found\r\n"));
+ }
+
+ publisher
+ .publish(observation(MycServicePhase::Unready, false))
+ .expect("unready publication");
+ let unready = raw_request(address, b"GET /readyz HTTP/1.1\r\nhost: localhost\r\n\r\n").await;
+ let metrics = raw_request(address, b"GET /metrics HTTP/1.1\r\nhost: localhost\r\n\r\n").await;
+ assert!(response_text(&unready).starts_with("HTTP/1.1 503 Service Unavailable\r\n"));
+ assert!(response_text(&unready).ends_with("unready\n"));
+ assert!(response_text(&metrics).contains("radroots_myc_service_phase{phase=\"unready\"} 1\n"));
+ assert!(response_text(&metrics).contains("radroots_myc_service_ready 0\n"));
+ assert_ne!(
+ reader.snapshot().detailed_status_json().as_ptr(),
+ detail_pointer
+ );
+
+ cancellation.cancel();
+ assert_eq!(task.await.expect("serve task"), Ok(()));
+}
+
+#[tokio::test]
+async fn disabled_invalid_and_bind_failures_are_typed_source_free_and_redacted() {
+ let disabled = parse_myc_config_v1(CONFIG.as_bytes(), MycConfigProfile::Production)
+ .expect("disabled config");
+ let (_, reader) = myc_status_cache(
+ InstanceId::new("primary").expect("instance"),
+ observation(MycServicePhase::Ready, true),
+ )
+ .expect("status cache");
+ let disabled_error =
+ MycOperationsServer::new(&disabled, &reader).expect_err("disabled operations");
+ assert_eq!(disabled_error.kind(), MycOperationsErrorKind::Disabled);
+ assert_eq!(disabled_error.code(), "operations_disabled");
+ assert!(Error::source(&disabled_error).is_none());
+
+ let below_floor =
+ enabled_config(available_port()).replace("header_bytes = 8192", "header_bytes = 8191");
+ assert!(parse_myc_config_v1(below_floor.as_bytes(), MycConfigProfile::Production).is_err());
+
+ let occupied =
+ TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)).expect("occupied listener");
+ let port = occupied.local_addr().expect("occupied address").port();
+ let config = parse_myc_config_v1(
+ enabled_config(port).as_bytes(),
+ MycConfigProfile::Production,
+ )
+ .expect("enabled config");
+ let server = MycOperationsServer::new(&config, &reader).expect("server");
+ assert!(!format!("{server:?}").contains(&port.to_string()));
+ let bind_error = server.bind().await.expect_err("occupied bind");
+ assert_eq!(bind_error.kind(), MycOperationsErrorKind::Bind);
+ assert!(Error::source(&bind_error).is_none());
+ assert!(!format!("{bind_error:?} {bind_error}").contains(&port.to_string()));
+}