feat: retain typed session registration causes across host startup

This commit is contained in:
2026-11-04 15:29:35 +09:00
parent c63a750943
commit acbaaaa1d6
19 changed files with 1414 additions and 217 deletions
+1
View File
@@ -2,6 +2,7 @@
pub mod identity;
pub mod invocation;
pub mod session_failure;
pub mod startup_failure;
#[cfg(feature = "stream")]
pub mod stream;
+620
View File
@@ -0,0 +1,620 @@
//! Typed session-registration failures, not lifecycle or retry authority.
//!
//! Causes contain only fixed enum values. Stored or transferred attachments are
//! observations, never permission to disclose: Browser/Tool projections require
//! the caller's current visibility decision and fail closed without it.
use error_contract::{
Browser, Category, ContractError, Disclosure, FailureCause, FailureFacts, FailureIdentity,
InternalTransfer, OperationContext, ProjectFor, Storage, Tool, WireDetails, project_failure,
};
use serde::{Deserialize, Serialize};
use crate::startup_failure::StartupFailureAttachment;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub enum SessionRegistrationReason {
PermissionDenied,
AttributionMissing,
BindingMismatch,
Transport,
OutcomeUnknown,
InvalidResponse,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub enum SessionPrincipal {
Operator,
Subject,
Unknown,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub enum SessionOperation {
#[serde(rename = "session.record")]
Record,
}
/// No account/session identifiers, paths, or free-form diagnostics are retained.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct SessionRegistrationCause {
pub reason: SessionRegistrationReason,
pub principal: SessionPrincipal,
pub required_operation: SessionOperation,
}
impl SessionRegistrationCause {
pub const fn denied(principal: SessionPrincipal) -> Self {
Self::new(SessionRegistrationReason::PermissionDenied, principal)
}
pub const fn attribution_missing() -> Self {
Self::new(
SessionRegistrationReason::AttributionMissing,
SessionPrincipal::Subject,
)
}
pub const fn binding_mismatch() -> Self {
Self::new(
SessionRegistrationReason::BindingMismatch,
SessionPrincipal::Subject,
)
}
pub const fn transport() -> Self {
Self::new(
SessionRegistrationReason::Transport,
SessionPrincipal::Unknown,
)
}
pub const fn outcome_unknown() -> Self {
Self::new(
SessionRegistrationReason::OutcomeUnknown,
SessionPrincipal::Unknown,
)
}
pub const fn invalid_response() -> Self {
Self::new(
SessionRegistrationReason::InvalidResponse,
SessionPrincipal::Unknown,
)
}
const fn new(reason: SessionRegistrationReason, principal: SessionPrincipal) -> Self {
Self {
reason,
principal,
required_operation: SessionOperation::Record,
}
}
fn explanation(&self) -> &'static str {
match self.reason {
SessionRegistrationReason::PermissionDenied => {
"Session registration permission denied."
}
SessionRegistrationReason::AttributionMissing => {
"Session registration requires Subject attribution."
}
SessionRegistrationReason::BindingMismatch => {
"Session registration Subject binding does not match."
}
SessionRegistrationReason::Transport => "Session registration transport failed.",
SessionRegistrationReason::OutcomeUnknown => "Session registration outcome is unknown.",
SessionRegistrationReason::InvalidResponse => {
"Session registration returned an invalid response."
}
}
}
fn disclosure(&self, facts: &FailureFacts<Self>) -> Disclosure<Self> {
let mut disclosure = Disclosure::new(*self, self.explanation());
for context in &facts.context().operations {
if matches!(
(context.operation.as_str(), context.stage.as_str()),
("session.record", "authorization" | "registration")
| ("worker_startup", "binding" | "runtime")
) {
let mut context = context.clone();
context.operation_ref = None;
disclosure.context.operations.push(context);
}
}
disclosure
}
}
impl FailureCause for SessionRegistrationCause {
fn identity(&self) -> FailureIdentity {
let (reason, category) = match self.reason {
SessionRegistrationReason::PermissionDenied => {
("permission_denied", Category::Authorization)
}
SessionRegistrationReason::AttributionMissing => {
("attribution_missing", Category::Authorization)
}
SessionRegistrationReason::BindingMismatch => {
("binding_mismatch", Category::Authorization)
}
SessionRegistrationReason::Transport => ("transport", Category::Dependency),
SessionRegistrationReason::OutcomeUnknown => ("outcome_unknown", Category::Unknown),
SessionRegistrationReason::InvalidResponse => ("invalid_response", Category::Internal),
};
FailureIdentity {
domain: "session_registration",
reason,
category,
details_version: 1,
}
}
fn validate(&self) -> Result<(), ContractError> {
// All fields are closed enums; arbitrary diagnostic data cannot enter.
Ok(())
}
}
impl WireDetails for SessionRegistrationCause {
fn supports(domain: &str, reason: &str, version: u16) -> bool {
domain == "session_registration"
&& version == 1
&& matches!(
reason,
"permission_denied"
| "attribution_missing"
| "binding_mismatch"
| "transport"
| "outcome_unknown"
| "invalid_response"
)
}
}
impl std::fmt::Display for SessionRegistrationCause {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.explanation())
}
}
impl std::error::Error for SessionRegistrationCause {}
/// A current caller-supplied visibility decision, not a serialized capability.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct SessionFailureVisibility {
pub can_view_cause: bool,
}
impl ProjectFor<InternalTransfer> for SessionRegistrationCause {
type Authorization = ();
type Details = Self;
fn disclose(
&self,
facts: &FailureFacts<Self>,
_: &(),
) -> Result<Disclosure<Self>, ContractError> {
Ok(self.disclosure(facts))
}
}
impl ProjectFor<Storage> for SessionRegistrationCause {
type Authorization = ();
type Details = Self;
fn disclose(
&self,
facts: &FailureFacts<Self>,
_: &(),
) -> Result<Disclosure<Self>, ContractError> {
Ok(self.disclosure(facts))
}
}
impl ProjectFor<Browser> for SessionRegistrationCause {
type Authorization = SessionFailureVisibility;
type Details = Self;
fn disclose(
&self,
facts: &FailureFacts<Self>,
authorization: &SessionFailureVisibility,
) -> Result<Disclosure<Self>, ContractError> {
if !authorization.can_view_cause {
// Redaction must not substitute a different reason/category identity.
return Err(ContractError::InvalidDetails);
}
Ok(self.disclosure(facts))
}
}
impl ProjectFor<Tool> for SessionRegistrationCause {
type Authorization = SessionFailureVisibility;
type Details = Self;
fn disclose(
&self,
facts: &FailureFacts<Self>,
authorization: &SessionFailureVisibility,
) -> Result<Disclosure<Self>, ContractError> {
if !authorization.can_view_cause {
return Err(ContractError::InvalidDetails);
}
Ok(self.disclosure(facts))
}
}
/// Attach fixed registration/binding observations using internal-transfer policy.
/// This does not assert registration rollback, retry safety, or startup ownership.
pub fn session_registration_attachment(
cause: SessionRegistrationCause,
) -> Result<StartupFailureAttachment, ContractError> {
let mut facts = FailureFacts::new(cause)?;
facts.with_context(OperationContext::new("session.record", "registration")?)?;
facts.with_context(OperationContext::new("worker_startup", "binding")?)?;
let projected = project_failure::<InternalTransfer, _>(&facts, &())?;
StartupFailureAttachment::from_projected(&projected)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::startup_failure::StoredStartupFailureAttachment;
use error_contract::{
Audience, Budget, CausalLink, CorrelationRef, DecodeFailure, ProjectedFailure,
decode_failure, encode_projected_failure,
};
use serde_json::json;
fn budget() -> Budget {
Budget::new(
error_contract::MAX_FAILURE_BYTES,
error_contract::MAX_FAILURE_BYTES,
)
}
fn cases() -> [(
SessionRegistrationCause,
&'static str,
Category,
&'static str,
); 6] {
[
(
SessionRegistrationCause::denied(SessionPrincipal::Operator),
"permission_denied",
Category::Authorization,
"Session registration permission denied.",
),
(
SessionRegistrationCause::attribution_missing(),
"attribution_missing",
Category::Authorization,
"Session registration requires Subject attribution.",
),
(
SessionRegistrationCause::binding_mismatch(),
"binding_mismatch",
Category::Authorization,
"Session registration Subject binding does not match.",
),
(
SessionRegistrationCause::transport(),
"transport",
Category::Dependency,
"Session registration transport failed.",
),
(
SessionRegistrationCause::outcome_unknown(),
"outcome_unknown",
Category::Unknown,
"Session registration outcome is unknown.",
),
(
SessionRegistrationCause::invalid_response(),
"invalid_response",
Category::Internal,
"Session registration returned an invalid response.",
),
]
}
#[test]
fn constructors_serde_identity_and_fixed_explanations() {
for (cause, reason, category, explanation) in cases() {
let principal = match cause.reason {
SessionRegistrationReason::PermissionDenied => "operator",
SessionRegistrationReason::AttributionMissing
| SessionRegistrationReason::BindingMismatch => "subject",
_ => "unknown",
};
let value = serde_json::to_value(cause).unwrap();
assert_eq!(
value,
json!({
"reason": reason,
"principal": principal,
"required_operation": "session.record"
})
);
assert_eq!(
serde_json::from_value::<SessionRegistrationCause>(value).unwrap(),
cause
);
assert_eq!(cause.required_operation, SessionOperation::Record);
assert_eq!(
cause.identity(),
FailureIdentity {
domain: "session_registration",
reason,
category,
details_version: 1,
}
);
assert!(SessionRegistrationCause::supports(
"session_registration",
reason,
1
));
assert_eq!(cause.to_string(), explanation);
assert!(std::error::Error::source(&cause).is_none());
let facts = FailureFacts::new(cause).unwrap();
let projected = project_failure::<InternalTransfer, _>(&facts, &()).unwrap();
assert_eq!(projected.explanation(), explanation);
let encoded = encode_projected_failure(&projected, budget()).unwrap();
match decode_failure::<SessionRegistrationCause>(encoded.as_bytes(), budget()) {
DecodeFailure::Known(received) => assert_eq!(received.facts().cause(), &cause),
_ => panic!("expected known session-registration failure"),
}
}
for (principal, wire) in [
(SessionPrincipal::Operator, "operator"),
(SessionPrincipal::Subject, "subject"),
(SessionPrincipal::Unknown, "unknown"),
] {
let cause = SessionRegistrationCause::denied(principal);
assert_eq!(cause.principal, principal);
assert_eq!(
serde_json::to_value(cause).unwrap()["principal"],
json!(wire)
);
}
assert!(!SessionRegistrationCause::supports(
"worker_startup",
"transport",
1
));
assert!(!SessionRegistrationCause::supports(
"session_registration",
"future",
1
));
assert!(!SessionRegistrationCause::supports(
"session_registration",
"transport",
2
));
}
#[test]
fn serde_rejects_untyped_diagnostic_fields_and_unknown_enum_values() {
let valid = serde_json::to_value(SessionRegistrationCause::attribution_missing()).unwrap();
for (field, value) in [
("reason", json!("raw_error")),
("principal", json!("account-secret")),
("required_operation", json!("session.delete")),
("diagnostic", json!("/private/path TEST_ONLY_SECRET")),
] {
let mut changed = valid.clone();
changed[field] = value;
assert!(serde_json::from_value::<SessionRegistrationCause>(changed).is_err());
}
for field in ["reason", "principal", "required_operation"] {
let mut changed = valid.clone();
changed.as_object_mut().unwrap().remove(field);
assert!(serde_json::from_value::<SessionRegistrationCause>(changed).is_err());
}
}
#[test]
fn codec_distinguishes_unsupported_and_corrupt_and_replaces_received_prose() {
let facts = FailureFacts::new(SessionRegistrationCause::transport()).unwrap();
let projected = project_failure::<InternalTransfer, _>(&facts, &()).unwrap();
let encoded = encode_projected_failure(&projected, budget()).unwrap();
let original: serde_json::Value = serde_json::from_slice(encoded.as_bytes()).unwrap();
for (field, value) in [
("domain", json!("future_domain")),
("reason", json!("future_reason")),
("details_version", json!(2)),
] {
let mut changed = original.clone();
changed[field] = value;
assert!(matches!(
decode_failure::<SessionRegistrationCause>(
&serde_json::to_vec(&changed).unwrap(),
budget()
),
DecodeFailure::Unsupported(_)
));
}
for (field, value) in [
("reason", json!("permission_denied")),
("category", json!("authorization")),
] {
let mut changed = original.clone();
changed[field] = value;
assert!(matches!(
decode_failure::<SessionRegistrationCause>(
&serde_json::to_vec(&changed).unwrap(),
budget()
),
DecodeFailure::Corrupt(ContractError::InvalidDetails)
));
}
let mut changed = original;
changed["explanation"] = json!("TEST_ONLY_SECRET received prose");
changed["context"]["diagnostics"] = json!([
{"code": "TEST_ONLY_SECRET", "explanation": "TEST_ONLY_SECRET diagnostic"}
]);
let bytes = serde_json::to_vec(&changed).unwrap();
let DecodeFailure::Known(received) =
decode_failure::<SessionRegistrationCause>(&bytes, budget())
else {
panic!("expected known failure with untrusted supplementary fields");
};
let browser = project_failure::<Browser, _>(
received.facts(),
&SessionFailureVisibility {
can_view_cause: true,
},
)
.unwrap();
assert_eq!(
browser.explanation(),
"Session registration transport failed."
);
assert!(browser.context().diagnostics.is_empty());
let encoded = encode_projected_failure(&browser, budget()).unwrap();
assert!(
!std::str::from_utf8(encoded.as_bytes())
.unwrap()
.contains("TEST_ONLY_SECRET")
);
}
#[test]
fn attachment_storage_and_current_visibility_preserve_identity_without_granting_access() {
let allowed = SessionFailureVisibility {
can_view_cause: true,
};
let denied = SessionFailureVisibility::default();
for (cause, _, _, _) in cases() {
let attachment = session_registration_attachment(cause).unwrap();
match attachment.decode::<SessionRegistrationCause>() {
DecodeFailure::Known(received) => {
assert_eq!(received.facts().cause(), &cause);
assert_eq!(
received.facts().context().operations,
vec![
OperationContext::new("session.record", "registration").unwrap(),
OperationContext::new("worker_startup", "binding").unwrap(),
]
);
}
_ => panic!("expected typed startup attachment"),
}
let storage = attachment
.project_for::<Storage, SessionRegistrationCause>(&())
.unwrap();
let stored = StoredStartupFailureAttachment::from_projected(&storage).unwrap();
let loaded: StoredStartupFailureAttachment =
serde_json::from_str(&serde_json::to_string(&stored).unwrap()).unwrap();
let browser = loaded
.project_for::<Browser, SessionRegistrationCause>(&allowed)
.unwrap();
let tool = loaded
.project_for::<Tool, SessionRegistrationCause>(&allowed)
.unwrap();
assert_eq!(browser.details(), &cause);
assert_eq!(tool.details(), &cause);
assert_eq!(browser.identity(), cause.identity());
assert_eq!(tool.identity(), cause.identity());
assert!(matches!(
loaded.project_for::<Browser, SessionRegistrationCause>(&denied),
Err(ContractError::InvalidDetails)
));
assert!(matches!(
loaded.project_for::<Tool, SessionRegistrationCause>(&denied),
Err(ContractError::InvalidDetails)
));
// Even a previous public projection cannot serve as current permission.
let previously_public = StartupFailureAttachment::from_projected(&browser).unwrap();
assert!(matches!(
previously_public.project_for::<Browser, SessionRegistrationCause>(&denied),
Err(ContractError::InvalidDetails)
));
assert!(matches!(
previously_public.project_for::<Tool, SessionRegistrationCause>(&denied),
Err(ContractError::InvalidDetails)
));
}
}
fn assert_redacted<A: Audience>(projected: &ProjectedFailure<A, SessionRegistrationCause>) {
assert_eq!(
projected.context().operations,
vec![
OperationContext::new("session.record", "authorization").unwrap(),
OperationContext::new("session.record", "registration").unwrap(),
OperationContext::new("worker_startup", "binding").unwrap(),
OperationContext::new("worker_startup", "runtime").unwrap(),
]
);
assert!(projected.context().correlation.is_none());
assert!(projected.context().causes.is_empty());
assert!(projected.context().diagnostics.is_empty());
let encoded = encode_projected_failure(projected, budget()).unwrap();
let wire = std::str::from_utf8(encoded.as_bytes()).unwrap();
for secret in ["TEST_ONLY_SECRET", "/private/path", "raw_stage"] {
assert!(!wire.contains(secret));
}
}
#[test]
fn every_audience_allowlists_operation_stage_pairs_and_drops_references() {
let mut facts = FailureFacts::new(SessionRegistrationCause::binding_mismatch()).unwrap();
for (operation, stage) in [
("session.record", "authorization"),
("session.record", "registration"),
("worker_startup", "binding"),
("worker_startup", "runtime"),
("session.record", "binding"),
("worker_startup", "authorization"),
("/private/path", "registration"),
("session.record", "raw_stage"),
] {
let mut context = OperationContext::new(operation, stage).unwrap();
context.operation_ref = Some("TEST_ONLY_SECRET operation reference".into());
facts.with_context(context).unwrap();
}
facts
.set_correlation(CorrelationRef::new("TEST_ONLY_SECRET correlation").unwrap())
.unwrap();
facts
.with_causal_link(CausalLink {
domain: "TEST_ONLY_SECRET domain".into(),
reason: "TEST_ONLY_SECRET reason".into(),
correlation: CorrelationRef::new("TEST_ONLY_SECRET causal reference").unwrap(),
})
.unwrap();
let allowed = SessionFailureVisibility {
can_view_cause: true,
};
assert_redacted(&project_failure::<InternalTransfer, _>(&facts, &()).unwrap());
assert_redacted(&project_failure::<Storage, _>(&facts, &()).unwrap());
assert_redacted(&project_failure::<Browser, _>(&facts, &allowed).unwrap());
assert_redacted(&project_failure::<Tool, _>(&facts, &allowed).unwrap());
// Projection selects output without mutating the owner's facts.
assert_eq!(facts.context().operations.len(), 8);
assert!(
facts
.context()
.operations
.iter()
.all(|context| context.operation_ref.is_some())
);
}
}
+13
View File
@@ -88,6 +88,10 @@ pub struct RepositoryApiError {
pub message: String,
#[serde(default)]
pub diagnostics: Vec<Diagnostic>,
/// An origin-projected error-contract attachment, not raw HTTP error text.
/// Receiving it does not authorize redisclosure to a different audience.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure: Option<protocol::startup_failure::StartupFailureAttachment>,
#[serde(skip, default = "default_repository_error_status")]
status: u16,
}
@@ -107,9 +111,18 @@ impl RepositoryApiError {
error: error.into(),
message: message.into(),
diagnostics,
failure: None,
status,
}
}
pub fn with_failure(
mut self,
failure: protocol::startup_failure::StartupFailureAttachment,
) -> Self {
self.failure = Some(failure);
self
}
}
impl api_macros::HttpError for RepositoryApiError {
+67 -9
View File
@@ -30,6 +30,7 @@ use std::sync::{
use feature_storage::FeatureStorage;
use fs4::fs_std::FileExt;
use protocol::session_failure::{SessionPrincipal, SessionRegistrationCause};
use serde::{Deserialize, Serialize};
use server_api::*;
use subjektiv::{SubjectRecord, SubjectRole, SubjectSessionAttribution, SubjektivStore};
@@ -418,6 +419,49 @@ impl LocalSubjectHost {
}
Ok(())
}
fn registration_authority(
&self,
context: &SubjektivHostContext,
) -> Result<(), SubjektivHostError> {
if !self.live.load(Ordering::Acquire) {
return Err(SubjektivHostError::SessionRegistration {
cause: SessionRegistrationCause::denied(SessionPrincipal::Subject),
source: None,
});
}
if !self
.catalog
.store
.journal_writer_active(&self.writer)
.map_err(|error| {
SubjektivHostError::session_registration(
SessionRegistrationCause::outcome_unknown(),
error,
)
})?
{
return Err(SubjektivHostError::SessionRegistration {
cause: SessionRegistrationCause::binding_mismatch(),
source: None,
});
}
if context.worker_id != self.worker_key && context.worker_id != self.worker_id.to_string() {
return Err(SubjektivHostError::SessionRegistration {
cause: SessionRegistrationCause::binding_mismatch(),
source: None,
});
}
context
.session_id
.parse::<session_store::SessionId>()
.map_err(|error| {
SubjektivHostError::session_registration(
SessionRegistrationCause::invalid_response(),
error,
)
})?;
Ok(())
}
fn attribution(
&self,
context: &SubjektivHostContext,
@@ -427,15 +471,16 @@ impl LocalSubjectHost {
let store = &self.catalog.store;
if let Some(record) = store
.session_attribution(&context.session_id)
.map_err(domain_error)?
.map_err(registration_domain_error)?
{
if record.subject_id != self.binding.subject_id
|| record.runtime_id != self.binding.scope_id
|| record.worker_id != self.worker_id.to_string()
{
return Err(SubjektivHostError::InvalidArgument(
"Session does not belong to this local Subject/Worker scope".into(),
));
return Err(SubjektivHostError::SessionRegistration {
cause: SessionRegistrationCause::binding_mismatch(),
source: None,
});
}
store
.bind_journal_session(&self.writer, record.clone())
@@ -443,9 +488,10 @@ impl LocalSubjectHost {
return Ok(record);
}
if !create {
return Err(SubjektivHostError::InvalidArgument(
"Session attribution is unavailable".into(),
));
return Err(SubjektivHostError::SessionRegistration {
cause: SessionRegistrationCause::attribution_missing(),
source: None,
});
}
// The capability is bound before controller startup. The Worker supplies
// the Session it owns; models cannot call this Host API or select one.
@@ -455,10 +501,10 @@ impl LocalSubjectHost {
self.worker_id.to_string(),
&context.session_id,
)
.map_err(domain_error)?;
.map_err(registration_domain_error)?;
let record = store
.record_session_attribution(record)
.map_err(domain_error)?;
.map_err(registration_domain_error)?;
store
.bind_journal_session(&self.writer, record.clone())
.map_err(journal::journal_error)?;
@@ -466,6 +512,17 @@ impl LocalSubjectHost {
}
}
fn registration_domain_error(error: subjektiv::SubjektivError) -> SubjektivHostError {
let cause = match &error {
subjektiv::SubjektivError::SessionSubjectConflict { .. }
| subjektiv::SubjektivError::SessionAttributionConflict { .. } => {
SessionRegistrationCause::binding_mismatch()
}
_ => SessionRegistrationCause::outcome_unknown(),
};
SubjektivHostError::session_registration(cause, error)
}
fn domain_error(error: subjektiv::SubjektivError) -> SubjektivHostError {
operation_error(subjektiv::api::subjektiv_store_error(error))
}
@@ -1036,6 +1093,7 @@ impl SubjektivHost for LocalSubjectHost {
context: &SubjektivHostContext,
create_if_missing: bool,
) -> Result<SubjektivRecordSessionResponse, SubjektivHostError> {
self.registration_authority(context)?;
let attribution = self.attribution(context, create_if_missing)?;
self.reserve_journal_session(context)?;
Ok(SubjektivRecordSessionResponse {
+50
View File
@@ -267,3 +267,53 @@ fn consolidation_operation_policy_is_attempt_bound_and_does_not_inherit_body_rig
.is_err()
);
}
#[test]
fn session_registration_uses_real_capability_and_attribution_decisions() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("state");
let catalog = StandaloneSubjects::open(&root).unwrap();
let subject = catalog.create("registration-owner", None).unwrap();
let id = protocol::WorkerId::now_v7();
let mut connection = catalog.connect(&subject.id, id).unwrap();
let port = connection.host.clone();
let context = SubjektivHostContext {
worker_id: format!("standalone-{id}"),
session_id: session_store::SessionId::now_v7().to_string(),
};
assert_eq!(
port.record_session(&context, false)
.unwrap_err()
.session_registration_cause(),
SessionRegistrationCause::attribution_missing(),
);
let foreign = SubjektivHostContext {
worker_id: "unbound-private-worker".into(),
..context.clone()
};
assert_eq!(
port.record_session(&foreign, true)
.unwrap_err()
.session_registration_cause(),
SessionRegistrationCause::binding_mismatch(),
);
let success = port.record_session(&context, true).unwrap();
assert_eq!(success.subject_id, subject.id);
assert_eq!(success.session_id, context.session_id);
assert!(port.record_session(&context, false).is_ok());
let reopened = StandaloneSubjects::open_existing(&root).unwrap();
let actual = reopened
.store
.session_attribution(&context.session_id)
.unwrap()
.unwrap();
assert_eq!(actual.subject_id, subject.id);
assert_eq!(actual.worker_id, id.to_string());
connection.close().unwrap();
assert_eq!(
port.record_session(&context, true)
.unwrap_err()
.session_registration_cause(),
SessionRegistrationCause::denied(SessionPrincipal::Subject),
);
}
+3 -1
View File
@@ -83,7 +83,9 @@ impl JournalCompactionFailure {
SubjektivHostError::PermissionDenied(_) => Self::SnapshotPermissionDenied,
SubjektivHostError::Conflict { .. } => Self::SnapshotConflict,
SubjektivHostError::InvalidArgument(_) => Self::SnapshotInvalidArgument,
SubjektivHostError::Unavailable(_) => Self::SnapshotUnavailable,
SubjektivHostError::Unavailable(_) | SubjektivHostError::SessionRegistration { .. } => {
Self::SnapshotUnavailable
}
}
}
+72
View File
@@ -9,6 +9,18 @@ use protocol::startup_failure::{
};
impl WorkerError {
pub fn session_registration_cause(
&self,
) -> Option<protocol::session_failure::SessionRegistrationCause> {
match self {
Self::SessionRegistration { source } => Some(source.session_registration_cause()),
Self::StartupFailure { source, .. }
| Self::ConstructionCleanup { source, .. }
| Self::RestoreActivationEffects { source } => source.session_registration_cause(),
_ => None,
}
}
pub fn with_startup_failure(self, failure: StartupFailureAttachment) -> Self {
Self::StartupFailure {
source: Box::new(self),
@@ -55,6 +67,9 @@ impl WorkerError {
if let Some(failure) = self.startup_failure() {
return Ok(failure.clone());
}
if let Some(cause) = self.session_registration_cause() {
return protocol::session_failure::session_registration_attachment(cause);
}
let reason = match self {
Self::ConstructionCleanup { source, .. }
| Self::RestoreActivationEffects { source } => return source.startup_failure_at(stage),
@@ -91,6 +106,63 @@ pub fn runtime_startup_attachment(
StartupFailureAttachment::from_projected(&projected)
}
/// Dispatch by the domain attested in process, not by parsing attachment fields.
pub(crate) fn persist_binding_startup_failure<S: session_store::WorkerMetadataStore>(
store: &S,
expected: &session_store::worker_metadata::StartupFailureExpectation,
source: WorkerError,
) -> WorkerError {
if source.session_registration_cause().is_some() {
persist_startup_failure::<_, protocol::session_failure::SessionRegistrationCause>(
store,
expected,
source,
StartupStage::Binding,
&(),
)
} else {
persist_startup_failure::<_, RuntimeStartupCause>(
store,
expected,
source,
StartupStage::Binding,
&(),
)
}
}
/// An attribution-state write failure supplements the registration decision. It
/// neither establishes the write's absence nor replaces the primary Error source.
pub(crate) fn retain_attribution_save_failure(
source: WorkerError,
saved: Result<(), WorkerError>,
) -> WorkerError {
use protocol::startup_failure::{StartupFailurePhase, StartupPersistenceOutcome};
let Err(error) = saved else {
return source;
};
let outcome = match error {
WorkerError::SubjektivSessionAttribution {
state: "attribution_conflict",
..
} => StartupPersistenceOutcome::Conflict,
WorkerError::WorkerStore(_) => StartupPersistenceOutcome::OutcomeUnknown,
_ => StartupPersistenceOutcome::NotSaved,
};
let failure = source
.startup_failure_at(StartupStage::Binding)
.expect("fixed Session cause fits the shared codec budget");
WorkerError::StartupFailure {
source: Box::new(source),
failure,
secondary: vec![StartupSecondaryFailure::Persistence {
phase: StartupFailurePhase::MetadataSave,
outcome,
}],
metadata_save: None,
}
}
/// Bind a caller-owned cause to the exact pre-observed Session/attribution. Saving
/// never replaces the original error, changes ownership, or issues retry authority.
pub fn persist_startup_failure<S, D>(
+112 -6
View File
@@ -64,6 +64,12 @@ pub struct SubjektivHostSettings {
#[derive(Debug, thiserror::Error)]
pub enum SubjektivHostError {
#[error("{cause}")]
SessionRegistration {
cause: protocol::session_failure::SessionRegistrationCause,
#[source]
source: Option<Box<dyn std::error::Error + Send + Sync>>,
},
#[error("{message}")]
Conflict { code: String, message: String },
#[error("{0}")]
@@ -74,6 +80,30 @@ pub enum SubjektivHostError {
Unavailable(String),
}
impl SubjektivHostError {
pub fn session_registration(
cause: protocol::session_failure::SessionRegistrationCause,
source: impl std::error::Error + Send + Sync + 'static,
) -> Self {
Self::SessionRegistration {
cause,
source: Some(Box::new(source)),
}
}
pub fn session_registration_cause(
&self,
) -> protocol::session_failure::SessionRegistrationCause {
use protocol::session_failure::SessionRegistrationCause;
match self {
Self::SessionRegistration { cause, .. } => cause.clone(),
// Old Host implementations have not attested a domain verdict. Their prose
// must not become a permission or binding decision at the caller.
_ => SessionRegistrationCause::outcome_unknown(),
}
}
}
impl From<SubjektivHostError> for ToolError {
fn from(error: SubjektivHostError) -> Self {
match error {
@@ -85,6 +115,9 @@ impl From<SubjektivHostError> for ToolError {
Self::ExecutionFailed(format!("Subjektiv operation permission denied: {message}"))
}
SubjektivHostError::Unavailable(message) => Self::ExecutionFailed(message),
SubjektivHostError::SessionRegistration { cause, .. } => {
Self::ExecutionFailed(cause.to_string())
}
}
}
}
@@ -249,6 +282,84 @@ impl BackendSubjektivHost {
}
}
/// Session registration is an internal operation boundary, not a Browser HTTP
/// classifier. Only the domain's codec attestation can establish a denial.
/// Neither legacy bodies nor transport error prose are retained in metadata.
pub(crate) fn record_backend_session(
client: &dyn WorkspaceClient,
session_id: &str,
create_if_missing: bool,
) -> Result<SubjektivRecordSessionResponse, SubjektivHostError> {
use protocol::session_failure::SessionRegistrationCause;
let response = client
.execute_server_operation(WorkspaceServerOperation::SubjektivRecordSession(
SubjektivRecordSessionRequest {
session_id: session_id.into(),
create_if_missing,
},
))
.map_err(|error| {
let cause = match &error {
crate::WorkspaceClientError::Unavailable(_) => {
SessionRegistrationCause::transport()
}
crate::WorkspaceClientError::Request(_) => {
SessionRegistrationCause::outcome_unknown()
}
crate::WorkspaceClientError::InvalidPath(_) => {
SessionRegistrationCause::invalid_response()
}
};
SubjektivHostError::session_registration(cause, error)
})?;
if !response.is_success() {
let cause = match serde_json::from_str::<RepositoryApiError>(&response.body) {
Ok(error) => match error.failure {
Some(failure) => match failure.decode::<SessionRegistrationCause>() {
error_contract::DecodeFailure::Known(received) => {
received.facts().cause().clone()
}
error_contract::DecodeFailure::Unsupported(_) => {
SessionRegistrationCause::outcome_unknown()
}
error_contract::DecodeFailure::Corrupt(_) => {
SessionRegistrationCause::invalid_response()
}
},
None => SessionRegistrationCause::outcome_unknown(),
},
Err(_) => SessionRegistrationCause::invalid_response(),
};
return Err(SubjektivHostError::SessionRegistration {
cause,
source: None,
});
}
let output = serde_json::from_str(&response.body).map_err(|error| {
SubjektivHostError::session_registration(
SessionRegistrationCause::invalid_response(),
error,
)
})?;
validate_session_registration(output, session_id)
}
pub(crate) fn validate_session_registration(
output: SubjektivRecordSessionResponse,
session_id: &str,
) -> Result<SubjektivRecordSessionResponse, SubjektivHostError> {
if output.session_id != session_id
|| output.subject_id.trim().is_empty()
|| output.subject_id.chars().any(char::is_control)
{
return Err(SubjektivHostError::SessionRegistration {
cause: protocol::session_failure::SessionRegistrationCause::invalid_response(),
source: None,
});
}
Ok(output)
}
fn decode<T: serde::de::DeserializeOwned>(
response: crate::WorkspaceResponse,
) -> Result<T, SubjektivHostError> {
@@ -490,12 +601,7 @@ impl SubjektivHost for BackendSubjektivHost {
context: &SubjektivHostContext,
create_if_missing: bool,
) -> Result<SubjektivRecordSessionResponse, SubjektivHostError> {
self.execute(WorkspaceServerOperation::SubjektivRecordSession(
SubjektivRecordSessionRequest {
session_id: context.session_id.clone(),
create_if_missing,
},
))
record_backend_session(self.client.as_ref(), &context.session_id, create_if_missing)
}
fn request_consolidation(
&self,
+47 -144
View File
@@ -9406,14 +9406,6 @@ fn incomplete_subjektiv_attribution_state(
}
}
fn incomplete_subjektiv_attribution_label(create_if_missing: bool) -> &'static str {
if create_if_missing {
"outcome_unknown"
} else {
"legacy_unknown"
}
}
fn bounded_attribution_diagnostic(mut diagnostic: String) -> String {
const MAX_CHARS: usize = 1024;
if diagnostic.chars().count() > MAX_CHARS {
@@ -9721,15 +9713,10 @@ where
&mut expected,
)
.map_err(|error| match expected {
Some(expected) => crate::startup_failure::persist_startup_failure::<
_,
protocol::startup_failure::RuntimeStartupCause,
>(
Some(expected) => crate::startup_failure::persist_binding_startup_failure(
&self.store,
&expected,
error,
protocol::startup_failure::StartupStage::Binding,
&(),
),
None => error,
})
@@ -9855,145 +9842,55 @@ where
pending_attribution.clone(),
)?;
let output = if self.subjektiv_host.is_some() {
let connection = self
.subjektiv_host_connection()
.map_err(|error| WorkerError::SubjektivSessionAttribution {
state: "failed",
message: error.to_string(),
})?
.ok_or_else(|| WorkerError::SubjektivSessionAttribution {
state: "failed",
message: "Session attribution requires enabled explicit Host capability".into(),
})?;
match connection
.host
.record_session(&connection.context, create_if_missing)
{
Ok(output) => output,
Err(error) => {
let message = bounded_attribution_diagnostic(error.to_string());
let rejected =
!matches!(error, crate::subjektiv::SubjektivHostError::Unavailable(_));
let state = if create_if_missing && rejected {
SubjektivSessionAttributionState::Failed {
session_id,
diagnostic: message.clone(),
}
} else {
incomplete_subjektiv_attribution_state(
session_id,
create_if_missing,
message.clone(),
)
};
persist_attribution(Some(&pending_attribution), state)?;
return Err(WorkerError::SubjektivSessionAttribution {
state: if create_if_missing && rejected {
"failed"
} else {
incomplete_subjektiv_attribution_label(create_if_missing)
},
message,
});
}
}
self.subjektiv_host_connection()
.map_err(|error| crate::subjektiv::SubjektivHostError::session_registration(
protocol::session_failure::SessionRegistrationCause::outcome_unknown(), error,
))
.and_then(|connection| connection.ok_or_else(|| {
crate::subjektiv::SubjektivHostError::SessionRegistration {
cause: protocol::session_failure::SessionRegistrationCause::binding_mismatch(),
source: None,
}
}))
.and_then(|connection| connection.host.record_session(&connection.context, create_if_missing))
} else {
let response = match self.workspace_client().execute_server_operation(
WorkspaceServerOperation::SubjektivRecordSession(
server_api::SubjektivRecordSessionRequest {
session_id: session_id.to_string(),
create_if_missing,
},
),
) {
Ok(response) => response,
Err(error) => {
let message = bounded_attribution_diagnostic(format!(
"Session attribution transport outcome is unknown: {error}"
));
persist_attribution(
Some(&pending_attribution),
incomplete_subjektiv_attribution_state(
session_id,
create_if_missing,
message.clone(),
),
)?;
return Err(WorkerError::SubjektivSessionAttribution {
state: incomplete_subjektiv_attribution_label(create_if_missing),
message,
});
}
};
if !response.is_success() {
let message = bounded_attribution_diagnostic(format!(
"Session attribution was rejected with HTTP {}: {}",
response.status, response.body
));
let state = if create_if_missing {
crate::subjektiv::record_backend_session(
self.workspace_client(), &session_id.to_string(), create_if_missing,
)
}.and_then(|output| crate::subjektiv::validate_session_registration(output, &session_id.to_string()));
let output = match output {
Ok(output) => output,
Err(error) => {
use protocol::session_failure::SessionRegistrationReason;
let cause = error.session_registration_cause();
let rejected = matches!(
cause.reason,
SessionRegistrationReason::PermissionDenied
| SessionRegistrationReason::AttributionMissing
| SessionRegistrationReason::BindingMismatch
);
// The legacy lifecycle diagnostic is a fixed domain label, never a
// transport body, credential, path or upstream Display string.
let diagnostic = cause.to_string();
let next = if create_if_missing && rejected {
SubjektivSessionAttributionState::Failed {
session_id,
diagnostic: message.clone(),
diagnostic,
}
} else {
SubjektivSessionAttributionState::LegacyUnknown {
incomplete_subjektiv_attribution_state(
session_id,
diagnostic: message.clone(),
}
create_if_missing,
diagnostic,
)
};
persist_attribution(Some(&pending_attribution), state)?;
return Err(WorkerError::SubjektivSessionAttribution {
state: if create_if_missing {
"failed"
} else {
"legacy_unknown"
},
message,
});
let source = WorkerError::SessionRegistration { source: error };
let saved = persist_attribution(Some(&pending_attribution), next);
return Err(crate::startup_failure::retain_attribution_save_failure(
source, saved,
));
}
let output: server_api::SubjektivRecordSessionResponse =
match serde_json::from_str(&response.body) {
Ok(output) => output,
Err(error) => {
let message = bounded_attribution_diagnostic(format!(
"Session attribution response outcome is unknown: {error}"
));
persist_attribution(
Some(&pending_attribution),
incomplete_subjektiv_attribution_state(
session_id,
create_if_missing,
message.clone(),
),
)?;
return Err(WorkerError::SubjektivSessionAttribution {
state: incomplete_subjektiv_attribution_label(create_if_missing),
message,
});
}
};
output
};
if output.session_id != session_id.to_string()
|| output.subject_id.trim().is_empty()
|| output.subject_id.chars().any(char::is_control)
{
let message =
"Session attribution response did not confirm the requested immutable identity"
.to_string();
persist_attribution(
Some(&pending_attribution),
incomplete_subjektiv_attribution_state(
session_id,
create_if_missing,
message.clone(),
),
)?;
return Err(WorkerError::SubjektivSessionAttribution {
state: incomplete_subjektiv_attribution_label(create_if_missing),
message,
});
}
persist_attribution(
Some(&pending_attribution),
SubjektivSessionAttributionState::Confirmed {
@@ -11555,6 +11452,12 @@ pub enum WorkerError {
#[error("feature lifecycle rejected operation: {0}")]
FeatureLifecycle(String),
#[error("Session registration failed: {source}")]
SessionRegistration {
#[source]
source: crate::subjektiv::SubjektivHostError,
},
#[error("Subjektiv Session attribution lifecycle is {state}: {message}")]
SubjektivSessionAttribution {
state: &'static str,
+152 -35
View File
@@ -28,6 +28,7 @@ use subjektiv::api::*;
mod backend_job_profile;
mod chat;
mod external_operation_audit;
mod session_registration;
mod subject_authorization;
mod subject_body;
mod subjektiv_deletion;
@@ -19529,7 +19530,12 @@ fn source_ref_readiness_blocker(code: &str) -> &'static str {
}
fn remap_source_ref_error(error: ApiError) -> ApiError {
let ApiError { error, diagnostics } = error;
let ApiError {
error,
diagnostics,
session_registration_cause,
session_registration_source,
} = error;
match error {
Error::RuntimeOperationFailed {
runtime_id,
@@ -19541,7 +19547,12 @@ fn remap_source_ref_error(error: ApiError) -> ApiError {
message,
}
.into(),
error => ApiError { error, diagnostics },
error => ApiError {
error,
diagnostics,
session_registration_cause,
session_registration_source,
},
}
}
@@ -20145,7 +20156,9 @@ async fn scoped_list_merge_requests(
}
fn merge_ref_diagnostic(error: ApiError) -> MergeRequestRefDiagnostic {
let ApiError { error, diagnostics } = error;
let ApiError {
error, diagnostics, ..
} = error;
diagnostics
.into_iter()
.next()
@@ -24275,19 +24288,51 @@ fn subjektiv_worker_scope_from_store(
api: &WorkspaceApi,
workspace_id: &str,
context: &server_api::ServerRequestContext,
) -> ApiResult<SubjektivStoredWorkerScope> {
subjektiv_worker_scope_with_judgment(
api,
workspace_id,
context,
session_registration::Judgment::Other,
)
}
fn subjektiv_worker_scope_with_judgment(
api: &WorkspaceApi,
workspace_id: &str,
context: &server_api::ServerRequestContext,
judgment: session_registration::Judgment,
) -> ApiResult<SubjektivStoredWorkerScope> {
let source = context.runtime_source.as_ref().ok_or_else(|| {
Error::WorkspacePermissionDenied(
"subjektiv operations require authenticated Runtime-owned source authority".to_string(),
judgment.error(
Error::WorkspacePermissionDenied(
"subjektiv operations require authenticated Runtime-owned source authority"
.to_string(),
),
protocol::session_failure::SessionRegistrationCause::denied(
if context.actor.is_some() {
protocol::session_failure::SessionPrincipal::Operator
} else {
protocol::session_failure::SessionPrincipal::Unknown
},
),
)
})?;
let worker_id = source.worker_id.as_deref().ok_or_else(|| {
Error::WorkspacePermissionDenied(
"subjektiv operations require Runtime-bound Worker identity".to_string(),
judgment.error(
Error::WorkspacePermissionDenied(
"subjektiv operations require Runtime-bound Worker identity".to_string(),
),
protocol::session_failure::SessionRegistrationCause::binding_mismatch(),
)
})?;
let worker = RuntimeWorkerRef::new(&source.runtime_id, worker_id);
if let Some(grant) = subjektiv_job::active_grant(api, workspace_id, &worker)? {
if let Some(grant) =
subjektiv_job::active_grant(api, workspace_id, &worker).map_err(|error| {
judgment
.permission_rejection(error, protocol::session_failure::SessionPrincipal::Subject)
})?
{
return Ok(SubjektivStoredWorkerScope {
subject_id: grant.subject_id,
worker,
@@ -24296,13 +24341,18 @@ fn subjektiv_worker_scope_from_store(
}
let lease = api
.store
.require_current_worker_singleton_owner(workspace_id, &worker)?
.require_current_worker_singleton_owner(workspace_id, &worker)
.map_err(|error| judgment.binding_rejection(error.into()))?
.ok_or_else(|| {
Error::WorkspacePermissionDenied(
"current Worker has no keyed singleton ownership".to_string(),
judgment.error(
Error::WorkspacePermissionDenied(
"current Worker has no keyed singleton ownership".to_string(),
),
protocol::session_failure::SessionRegistrationCause::binding_mismatch(),
)
})?;
let (subject_id, authority) = subjektiv_scope_from_singleton_key(&lease.key)?;
let (subject_id, authority) = subjektiv_scope_from_singleton_key(&lease.key)
.map_err(|error| judgment.binding_rejection(error))?;
let authority = match authority {
SubjektivWorkerAuthority::Subject => SubjektivStoredScopeAuthority::Subject,
SubjektivWorkerAuthority::Consolidation => {
@@ -24367,6 +24417,14 @@ fn authorize_subjektiv_operation(
resource: resource.clone(),
},
)
.map_err(|error| {
if operation == "session.record" {
session_registration::Judgment::SessionRecord
.permission_rejection(error, protocol::session_failure::SessionPrincipal::Subject)
} else {
error
}
})
// The binding query returns only the authorized concrete target, never a
// wildcard ceiling or a broader permission that happened to cover it.
.map(|scope| scope.map(|_| resource))
@@ -24387,18 +24445,23 @@ fn require_subjektiv_worker_authority(
}
}
fn subjektiv_session_attribution(
fn subjektiv_session_attribution_with_judgment(
api: &WorkspaceApi,
workspace_id: &str,
context: &server_api::ServerRequestContext,
session_id: &str,
judgment: session_registration::Judgment,
) -> ApiResult<crate::subjektiv::SubjectSessionAttribution> {
let scope = subjektiv_worker_scope_from_store(api, workspace_id, context)?;
let scope = subjektiv_worker_scope_with_judgment(api, workspace_id, context, judgment)?;
if !matches!(scope.authority, SubjektivStoredScopeAuthority::Subject) {
return Err(Error::WorkspacePermissionDenied(
"subject Session attribution requires the current subject Worker".into(),
)
.into());
return Err(judgment.error(
Error::WorkspacePermissionDenied(
"subject Session attribution requires the current subject Worker".into(),
),
protocol::session_failure::SessionRegistrationCause::denied(
protocol::session_failure::SessionPrincipal::Subject,
),
));
}
let session_id = session_id.trim();
if session_id.is_empty() || session_id.chars().any(char::is_control) {
@@ -24419,24 +24482,52 @@ fn require_existing_subjektiv_session_attribution(
context: &server_api::ServerRequestContext,
session_id: &str,
) -> ApiResult<crate::subjektiv::SubjectSessionAttribution> {
let expected = subjektiv_session_attribution(api, workspace_id, context, session_id)?;
require_existing_subjektiv_session_attribution_with_judgment(
api,
workspace_id,
context,
session_id,
session_registration::Judgment::Other,
)
}
fn require_existing_subjektiv_session_attribution_with_judgment(
api: &WorkspaceApi,
workspace_id: &str,
context: &server_api::ServerRequestContext,
session_id: &str,
judgment: session_registration::Judgment,
) -> ApiResult<crate::subjektiv::SubjectSessionAttribution> {
let expected = subjektiv_session_attribution_with_judgment(
api,
workspace_id,
context,
session_id,
judgment,
)?;
let existing = open_subjektiv_store(api)?
.session_attribution(&expected.session_id)
.map_err(subjektiv_store_error)?
.ok_or_else(|| {
Error::RepositoryConflict(format!(
"subjektiv_session_attribution_incomplete: Session {} has not completed its Host attribution lifecycle",
expected.session_id
))
judgment.error(
Error::RepositoryConflict(format!(
"subjektiv_session_attribution_incomplete: Session {} has not completed its Host attribution lifecycle",
expected.session_id
)),
protocol::session_failure::SessionRegistrationCause::attribution_missing(),
)
})?;
if existing.subject_id != expected.subject_id
|| existing.runtime_id != expected.runtime_id
|| existing.worker_id != expected.worker_id
{
return Err(Error::WorkspacePermissionDenied(
"existing Session attribution does not match the authorized subject Worker".to_string(),
)
.into());
return Err(judgment.error(
Error::WorkspacePermissionDenied(
"existing Session attribution does not match the authorized subject Worker"
.to_string(),
),
protocol::session_failure::SessionRegistrationCause::binding_mismatch(),
));
}
Ok(existing)
}
@@ -24855,18 +24946,20 @@ async fn scoped_record_subjektiv_session(
.run(move || {
validate_workspace_scope(&api, &path.workspace_id)?;
let attribution = if request.create_if_missing {
subjektiv_session_attribution(
subjektiv_session_attribution_with_judgment(
&api,
&path.workspace_id,
&context,
&request.session_id,
session_registration::Judgment::SessionRecord,
)?
} else {
require_existing_subjektiv_session_attribution(
require_existing_subjektiv_session_attribution_with_judgment(
&api,
&path.workspace_id,
&context,
&request.session_id,
session_registration::Judgment::SessionRecord,
)?
};
authorize_subjektiv_operation(
@@ -24876,13 +24969,14 @@ async fn scoped_record_subjektiv_session(
&attribution.subject_id,
"session.record",
)?;
subjektiv_journal::bind_host_session(&api, attribution.clone())?;
session_registration::bind_host_session(&api, attribution.clone())?;
Ok(Json(server_api::SubjektivRecordSessionResponse {
subject_id: attribution.subject_id,
session_id: attribution.session_id,
}))
})
.await
.map_err(session_registration::execution_error)
}
const SUBJEKTIV_SESSION_DEFAULT_LIMIT: usize = 20;
@@ -39369,6 +39463,8 @@ fn workspace_id_mismatch_error() -> ApiError {
struct ApiError {
error: Error,
diagnostics: Vec<RuntimeDiagnostic>,
session_registration_cause: Option<protocol::session_failure::SessionRegistrationCause>,
session_registration_source: Option<Box<dyn std::error::Error + Send + Sync>>,
}
#[derive(Debug, Clone)]
@@ -39582,7 +39678,12 @@ impl From<Error> for ApiError {
}
_ => Vec::new(),
};
Self { error, diagnostics }
Self {
error,
diagnostics,
session_registration_cause: None,
session_registration_source: None,
}
}
}
@@ -39599,13 +39700,21 @@ fn subjektiv_diagnostic_code(message: &str) -> Option<&'static str> {
impl ApiError {
fn with_diagnostics(error: Error, diagnostics: Vec<RuntimeDiagnostic>) -> Self {
Self { error, diagnostics }
Self {
error,
diagnostics,
session_registration_cause: None,
session_registration_source: None,
}
}
fn into_repository_api_error(self) -> server_api::RepositoryApiError {
let status = api_error_status(&self.error);
let message = api_error_response_message(&self.error);
server_api::RepositoryApiError::new(
let message = self
.session_registration_cause
.map(|cause| cause.to_string())
.unwrap_or_else(|| api_error_response_message(&self.error));
let response = server_api::RepositoryApiError::new(
status.as_u16(),
match self.error {
Error::RestoreObservationConflict => "restore_observation_conflict",
@@ -39616,7 +39725,15 @@ impl ApiError {
},
message,
working_directory_diagnostics(self.diagnostics),
)
);
match self.session_registration_cause {
Some(cause) => response.with_failure(
protocol::session_failure::session_registration_attachment(cause).expect(
"enum-only Session registration cause must satisfy the transfer contract",
),
),
None => response,
}
}
}
@@ -0,0 +1,212 @@
//! Session-record judgment projection. Only explicit domain decisions issue causes;
//! status codes, diagnostic prose and arbitrary backend errors are not classifiers.
use super::*;
use protocol::session_failure::{SessionPrincipal, SessionRegistrationCause};
#[derive(Clone, Copy)]
pub(super) enum Judgment {
Other,
SessionRecord,
}
impl Judgment {
pub(super) fn error(self, error: Error, cause: SessionRegistrationCause) -> ApiError {
self.attach(error.into(), cause)
}
fn attach(self, mut error: ApiError, cause: SessionRegistrationCause) -> ApiError {
if matches!(self, Self::SessionRecord) && error.session_registration_cause.is_none() {
error.session_registration_cause = Some(cause);
}
error
}
pub(super) fn permission_rejection(
self,
error: ApiError,
principal: SessionPrincipal,
) -> ApiError {
if matches!(&error.error, Error::WorkspacePermissionDenied(_)) {
self.attach(error, SessionRegistrationCause::denied(principal))
} else {
error
}
}
pub(super) fn binding_rejection(self, error: ApiError) -> ApiError {
if matches!(
&error.error,
Error::WorkspacePermissionDenied(_) | Error::SubjectBodySuperseded
) {
self.attach(error, SessionRegistrationCause::binding_mismatch())
} else {
error
}
}
}
pub(super) fn bind_host_session(
api: &WorkspaceApi,
attribution: crate::subjektiv::SubjectSessionAttribution,
) -> ApiResult<()> {
subjektiv_journal::bind_host_session_for_registration(api, attribution)
.map_err(|error| Judgment::SessionRecord.binding_rejection(error))
}
pub(super) fn journal_error(error: subjektiv::journal::JournalError) -> ApiError {
let cause = match &error {
subjektiv::journal::JournalError::PermissionDenied(_) => {
SessionRegistrationCause::binding_mismatch()
}
_ => SessionRegistrationCause::outcome_unknown(),
};
let mut failure = Judgment::SessionRecord.error(Error::Store(cause.to_string()), cause);
failure.session_registration_source = Some(Box::new(error));
failure
}
pub(super) fn domain_error(error: crate::subjektiv::SubjektivError) -> ApiError {
let cause = match &error {
crate::subjektiv::SubjektivError::SessionSubjectConflict { .. }
| crate::subjektiv::SubjektivError::SessionAttributionConflict { .. } => {
SessionRegistrationCause::binding_mismatch()
}
_ => SessionRegistrationCause::outcome_unknown(),
};
let mut failure = Judgment::SessionRecord.error(Error::InvalidInput(cause.to_string()), cause);
failure.session_registration_source = Some(Box::new(error));
failure
}
pub(super) fn execution_error(error: ApiError) -> ApiError {
// This typed executor result means the accepted callback's outcome cannot
// be established. It is not an authorization denial or proof of rollback.
if matches!(&error.error, Error::DbOperationOutcomeUnknown) {
Judgment::SessionRecord.attach(error, SessionRegistrationCause::outcome_unknown())
} else {
error
}
}
impl std::fmt::Display for ApiError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(&self.error, formatter)
}
}
impl std::error::Error for ApiError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self.session_registration_source.as_deref() {
Some(source) => Some(source),
None => Some(&self.error),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use api_macros::HttpError;
use protocol::session_failure::SessionRegistrationReason;
#[test]
fn permission_projection_keeps_principal_and_original_error() {
for principal in [SessionPrincipal::Operator, SessionPrincipal::Subject] {
let error = Judgment::SessionRecord.permission_rejection(
Error::WorkspacePermissionDenied("wording is not classification input".into())
.into(),
principal,
);
let cause = error.session_registration_cause.as_ref().unwrap();
assert_eq!(cause.reason, SessionRegistrationReason::PermissionDenied);
assert_eq!(cause.principal, principal);
assert_eq!(api_error_status(&error.error), StatusCode::FORBIDDEN);
assert!(
std::error::Error::source(&error)
.unwrap()
.downcast_ref::<Error>()
.is_some()
);
}
}
#[test]
fn unrelated_operations_and_non_denial_errors_stay_unclassified() {
let unrelated = Judgment::Other.permission_rejection(
Error::WorkspacePermissionDenied("session.record permission denied".into()).into(),
SessionPrincipal::Subject,
);
assert!(unrelated.session_registration_cause.is_none());
let backend = Judgment::SessionRecord.permission_rejection(
Error::Store("session.record permission denied".into()).into(),
SessionPrincipal::Subject,
);
assert!(backend.session_registration_cause.is_none());
assert!(backend.into_repository_api_error().failure.is_none());
}
#[test]
fn known_causes_are_not_replaced_by_outer_projection() {
for cause in [
SessionRegistrationCause::attribution_missing(),
SessionRegistrationCause::binding_mismatch(),
] {
let error = Judgment::SessionRecord.error(
Error::WorkspacePermissionDenied("private attribution diagnostic".into()),
cause,
);
let before = serde_json::to_value(error.session_registration_cause.as_ref()).unwrap();
let error =
Judgment::SessionRecord.permission_rejection(error, SessionPrincipal::Subject);
assert_eq!(
before,
serde_json::to_value(error.session_registration_cause.as_ref()).unwrap()
);
}
}
#[test]
fn unknown_executor_outcome_is_not_permission_denied() {
let error = execution_error(Error::DbOperationOutcomeUnknown.into());
assert_eq!(
error.session_registration_cause.unwrap().reason,
SessionRegistrationReason::OutcomeUnknown
);
assert!(
execution_error(Error::DbExecutorUnavailable.into())
.session_registration_cause
.is_none()
);
}
#[test]
fn all_enum_only_causes_satisfy_the_transfer_contract() {
for cause in [
SessionRegistrationCause::denied(SessionPrincipal::Operator),
SessionRegistrationCause::denied(SessionPrincipal::Subject),
SessionRegistrationCause::denied(SessionPrincipal::Unknown),
SessionRegistrationCause::attribution_missing(),
SessionRegistrationCause::binding_mismatch(),
SessionRegistrationCause::transport(),
SessionRegistrationCause::outcome_unknown(),
SessionRegistrationCause::invalid_response(),
] {
protocol::session_failure::session_registration_attachment(cause).unwrap();
}
}
#[test]
fn internal_transfer_contains_no_original_error_text() {
let error = Judgment::SessionRecord
.error(
Error::WorkspacePermissionDenied("secret-subject-id /private/path raw-body".into()),
SessionRegistrationCause::denied(SessionPrincipal::Subject),
)
.into_repository_api_error();
assert_eq!(error.status_code(), StatusCode::FORBIDDEN.as_u16());
let attachment = serde_json::to_string(error.failure.as_ref().unwrap()).unwrap();
for private in ["secret-subject-id", "/private/path", "raw-body"] {
assert!(!attachment.contains(private));
}
}
}
@@ -449,6 +449,13 @@ pub(super) fn bind_host_session(
api: &WorkspaceApi,
attr: crate::subjektiv::SubjectSessionAttribution,
) -> Result<()> {
bind_host_session_for_registration(api, attr).map_err(|failure| failure.error)
}
pub(super) fn bind_host_session_for_registration(
api: &WorkspaceApi,
attr: crate::subjektiv::SubjectSessionAttribution,
) -> ApiResult<()> {
let gate = lifecycle_lock(api, &attr.subject_id)?;
let _guard = gate
.lock()
@@ -465,16 +472,18 @@ pub(super) fn bind_host_session(
{
return Err(Error::RepositoryConflict(
"Subject release intent fences Controller publication".into(),
));
)
.into());
}
let store = store(api)?;
if store
.journal_worker_was_released(&attr.subject_id, &attr.runtime_id, &attr.worker_id)
.map_err(domain)?
.map_err(session_registration::journal_error)?
{
return Err(Error::WorkspacePermissionDenied(
"released body cannot reacquire Journal authority".into(),
));
)
.into());
}
// A released Backend body is never rebound by a delayed create/restore callback. New
// Backend bodies have new Worker IDs; their first callback has no historical generation.
@@ -484,7 +493,8 @@ pub(super) fn bind_host_session(
.into_iter()
.filter(|a| a.runtime_id == attr.runtime_id && a.worker_id == attr.worker_id)
{
let host = JournalHostContext::validated_session(&store, prior).map_err(domain)?;
let host = JournalHostContext::validated_session(&store, prior)
.map_err(session_registration::journal_error)?;
if let Some(id) = host.writer_binding_id() {
let binding = JournalWriterBinding {
subject_id: attr.subject_id.clone(),
@@ -492,10 +502,14 @@ pub(super) fn bind_host_session(
worker_id: attr.worker_id.clone(),
binding_id: id.into(),
};
if !store.journal_writer_active(&binding).map_err(domain)? {
if !store
.journal_writer_active(&binding)
.map_err(session_registration::journal_error)?
{
return Err(Error::WorkspacePermissionDenied(
"released body Session cannot reacquire Journal authority".into(),
));
)
.into());
}
}
}
@@ -503,11 +517,13 @@ pub(super) fn bind_host_session(
// authenticated before a recovery intent must not leave a late attribution.
let attr = store
.record_session_attribution(attr)
.map_err(|error| Error::InvalidInput(error.to_string()))?;
.map_err(session_registration::domain_error)?;
let writer = store
.claim_journal_writer(&attr.subject_id, &attr.runtime_id, &attr.worker_id)
.map_err(domain)?;
store.bind_journal_session(&writer, attr).map_err(domain)?;
.map_err(session_registration::journal_error)?;
store
.bind_journal_session(&writer, attr)
.map_err(session_registration::journal_error)?;
Ok(())
}
+16 -1
View File
@@ -11817,6 +11817,17 @@
"error": {
"type": "string"
},
"failure": {
"anyOf": [
{
"$ref": "#/components/schemas/StartupFailureAttachment"
},
{
"type": "null"
}
],
"description": "An origin-projected error-contract attachment, not raw HTTP error text.\nReceiving it does not authorize redisclosure to a different audience."
},
"message": {
"type": "string"
}
@@ -14799,6 +14810,10 @@
],
"type": "object"
},
"StartupFailureAttachment": {
"description": "A bounded, projected envelope at the Worker/Runtime or metadata boundary.\nNo constructor accepts Display text, arbitrary JSON fields, or an unprojected error.",
"type": "string"
},
"SubjectBodyApiError": {
"additionalProperties": false,
"description": "Only action handlers with a causally bound request may provide resolution evidence.",
@@ -23348,7 +23363,7 @@
"info": {
"title": "Yoi Server API",
"version": "0.1.0",
"x-yoi-source-digest": "sha256:28ee9c6c6e2a4eb71829386849b508fcee758fbd7ecdbc0cdcc72cd3b86be321"
"x-yoi-source-digest": "sha256:894ccff36a463bec91b635956924114aa0db87d8b3f815230d6e8395511d2f6e"
},
"openapi": "3.1.0",
"paths": {
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:28ee9c6c6e2a4eb71829386849b508fcee758fbd7ecdbc0cdcc72cd3b86be321)
// Input contract: 0.1.0 (sha256:894ccff36a463bec91b635956924114aa0db87d8b3f815230d6e8395511d2f6e)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -29,7 +29,9 @@ export type Diagnostic = { code: string; message: string; severity: DiagnosticSe
export type DiagnosticSeverity = "info" | "warning" | "error";
export type RepositoryApiError = { diagnostics?: Array<Diagnostic>; error: string; message: string; };
export type RepositoryApiError = { diagnostics?: Array<Diagnostic>; error: string; failure?: StartupFailureAttachment | null; message: string; };
export type StartupFailureAttachment = string;
export type WorkspaceWorkerDiscoveryItem = { display_name: string; profile?: string | null; resource_key: string; status?: string | null; subject: WorkspaceWorkerSubject; };
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:28ee9c6c6e2a4eb71829386849b508fcee758fbd7ecdbc0cdcc72cd3b86be321)
// Input contract: 0.1.0 (sha256:894ccff36a463bec91b635956924114aa0db87d8b3f815230d6e8395511d2f6e)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -29,7 +29,7 @@ export type HostSummary = { arch: string; diagnostics: Array<Diagnostic>; host_i
export type HostWorkerListResponse = { diagnostics: Array<Diagnostic>; items: Array<WorkerSummary>; limit: number; source: string; workspace_id: string; };
export type RepositoryApiError = { diagnostics?: Array<Diagnostic>; error: string; message: string; };
export type RepositoryApiError = { diagnostics?: Array<Diagnostic>; error: string; failure?: StartupFailureAttachment | null; message: string; };
export type RepositoryDetailResponse = { item: RepositorySummary; source: string; workspace_id: string; };
@@ -57,6 +57,8 @@ export type RepositorySshHostTrust = { created_at: string; fingerprint: string;
export type RepositorySummary = { default_selector?: string | null; diagnostics?: Array<RepositoryDiagnostic> | null; git?: GitRepositorySummary | null; kind: string; observed_at?: string | null; observed_status: RepositoryObservedStatus; provider: string; record_authority: string; repository_key: string; source: RepositorySource; source_fingerprint: string; };
export type StartupFailureAttachment = string;
export type SubscriptionWorkerAvailability = "observed" | "unavailable";
export type WorkerBusyState = { kind: "run"; state: WorkerRunState; } | { kind: "maintenance"; state: WorkerMaintenanceState; };
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:28ee9c6c6e2a4eb71829386849b508fcee758fbd7ecdbc0cdcc72cd3b86be321)
// Input contract: 0.1.0 (sha256:894ccff36a463bec91b635956924114aa0db87d8b3f815230d6e8395511d2f6e)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -63,7 +63,7 @@ export type PasteArtifactRef = { artifact_id: string; availability: PasteArtifac
export type RemoveRuntimeRequest = { expected_binding_id: string; operation_id: string; };
export type RepositoryApiError = { diagnostics?: Array<Diagnostic>; error: string; message: string; };
export type RepositoryApiError = { diagnostics?: Array<Diagnostic>; error: string; failure?: StartupFailureAttachment | null; message: string; };
export type RevokeRuntimeTrustKeyRequest = { expected_binding_id: string; };
@@ -155,6 +155,8 @@ export type RuntimeWorkerTicketAssignmentRequest = { operation_id: string; orche
export type Segment = { content: string; kind: "text"; } | { chars: number; content: string; id: number; kind: "paste"; lines: number; } | { artifact: PasteArtifactRef; kind: "paste_artifact"; } | { file: UploadedFileRef; kind: "uploaded_file"; } | { kind: "file_ref"; path: string; } | { invocation: FeatureInvocation; kind: "feature_invoke"; } | { kind: "flow"; selector: string; } | { kind: "unknown"; };
export type StartupFailureAttachment = string;
export type SubscriptionWorkerAvailability = "observed" | "unavailable";
export type UpdateRemoteRuntimeRequest = { display_name?: string | null; endpoint: string; };
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:28ee9c6c6e2a4eb71829386849b508fcee758fbd7ecdbc0cdcc72cd3b86be321)
// Input contract: 0.1.0 (sha256:894ccff36a463bec91b635956924114aa0db87d8b3f815230d6e8395511d2f6e)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -87,7 +87,7 @@ export type ObjectiveSummary = { created_at?: string | null; id: string; linked_
export type QueryPage = { has_more: boolean; limit: number; next_cursor?: string | null; returned: number; sort: string; source_limit?: number | null; source_truncated: boolean; };
export type RepositoryApiError = { diagnostics?: Array<Diagnostic>; error: string; message: string; };
export type RepositoryApiError = { diagnostics?: Array<Diagnostic>; error: string; failure?: StartupFailureAttachment | null; message: string; };
export type ReviewDecision = "approve" | "request_changes";
@@ -95,6 +95,8 @@ export type ReviewFinding = { body: string; code?: string | null; line?: number
export type SetTicketRoleAssignmentRequest = { expected_assignment_id?: string | null; operation_id: string; orchestrator_assignment_id?: string | null; principal: TicketAssignmentPrincipal; workdir_bindings?: Array<TicketWorkerAttachmentBinding>; };
export type StartupFailureAttachment = string;
export type TicketActionEligibility = { blockers: Array<string>; can_assign_orchestrator: boolean; can_queue: boolean; can_set_parent: boolean; can_start_manual_worker: boolean; can_unassign_orchestrator: boolean; queue_tickets: Array<string>; };
export type TicketAssignmentAnswer = { assignment_id: string; body: string; expected_last_event_id: string; operation_key: string; };
@@ -1,6 +1,6 @@
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:6a3d9c468ff204b44fc0b11d47e45f153b8131b9ec99c54ba2ebfdf2c0d14580)
// Input contract: 0.1.0 (sha256:894ccff36a463bec91b635956924114aa0db87d8b3f815230d6e8395511d2f6e)
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/workdir-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
@@ -15,7 +15,9 @@ export type ExternalWorkdirGrantResponse = { display_name: string; expires_at?:
export type ExternalWorkdirPermissions = { command: boolean; read: boolean; write: boolean; };
export type RepositoryApiError = { diagnostics?: Array<Diagnostic>; error: string; message: string; };
export type RepositoryApiError = { diagnostics?: Array<Diagnostic>; error: string; failure?: StartupFailureAttachment | null; message: string; };
export type StartupFailureAttachment = string;
export type WorkingDirectoryCleanupTarget = { kind: string; repository_key: string; working_directory_id: string; };
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:28ee9c6c6e2a4eb71829386849b508fcee758fbd7ecdbc0cdcc72cd3b86be321)
// Input contract: 0.1.0 (sha256:894ccff36a463bec91b635956924114aa0db87d8b3f815230d6e8395511d2f6e)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -37,7 +37,7 @@ export type PasteArtifactRef = { artifact_id: string; availability: PasteArtifac
export type PermissionResource = { installation_id: string; kind: "plugin_installation"; workspace_id: string; } | { job_id: string; kind: "job"; workspace_id: string; } | { kind: "ticket"; ticket_id: string; workspace_id: string; } | { kind: "objective"; objective_id: string; workspace_id: string; } | { kind: "merge_request"; merge_request_id: string; workspace_id: string; } | { kind: "subject"; subject_id: string; workspace_id: string; } | { kind: "drive_entry"; node_id: string; workspace_id: string; } | { kind: "drive_request"; request_id: string; workspace_id: string; } | { kind: "repository"; repository_key: string; workspace_id: string; } | { connection_id: string; kind: "workdir_attachment"; workspace_id: string; } | { kind: "workspace_config"; path: string; recursive: boolean; workspace_id: string; } | { kind: "network"; url: string; } | { kind: "sub_worker"; name: string; runtime_id: string; worker_id: string; } | { kind: "feature"; } | { kind: "workspace"; workspace_id: string; } | { kind: "workdir"; path: string; recursive: boolean; working_directory_id: string; } | { kind: "worker_operation"; workspace_id: string; } | { kind: "worker"; runtime_id: string; worker_id: string; };
export type RepositoryApiError = { diagnostics?: Array<Diagnostic>; error: string; message: string; };
export type RepositoryApiError = { diagnostics?: Array<Diagnostic>; error: string; failure?: StartupFailureAttachment | null; message: string; };
export type RuntimeWorkerWorkdirAttachmentSummary = { alias: string; working_directory: RuntimeWorkingDirectorySummary; };
@@ -49,6 +49,8 @@ export type ScopedPermission = { operation: string; resource: PermissionResource
export type Segment = { content: string; kind: "text"; } | { chars: number; content: string; id: number; kind: "paste"; lines: number; } | { artifact: PasteArtifactRef; kind: "paste_artifact"; } | { file: UploadedFileRef; kind: "uploaded_file"; } | { kind: "file_ref"; path: string; } | { invocation: FeatureInvocation; kind: "feature_invoke"; } | { kind: "flow"; selector: string; } | { kind: "unknown"; };
export type StartupFailureAttachment = string;
export type SubjektivWorkerConnectionRequest = { subject_id: string; };
export type SubscriptionWorkerAvailability = "observed" | "unavailable";