Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 81 additions & 30 deletions crates/tracedecay-api/src/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ use axum::Json;
use axum::http::StatusCode;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tracedecay_contracts::{ApplicationProblemEnvelope, ApplicationProblemKind};
use serde_json::Value;
use tracedecay_contracts::{
ApplicationProblemEnvelope, ApplicationProblemKind, SettingsValidationIssueV1,
};
use tracedecay_domain::configuration::{CodeIndexWorkerSelectionV1, ContextScoutIntentSettingsV1};
use tracedecay_tool_catalog::ApplicationSurfaceOperation;

Expand Down Expand Up @@ -89,8 +91,39 @@ pub struct CodeIndexWorkerSettingsPatch {
pub code_index_workers: CodeIndexWorkerSelectionV1,
}

/// Configuration refusals carrying the same stable codes as the HTTP routes.
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "code", rename_all = "snake_case", deny_unknown_fields)]
pub enum ConfigurationRefusalV1 {
ConfigurationRevisionConflict {
detail: String,
expected_revision_id: String,
actual_revision_id: String,
},
ConfigurationAuthorityUnavailable {
detail: String,
},
ApplicationContractViolation {
detail: String,
},
}

/// Every refusal body produced by the dashboard configuration adapter. The
/// untagged wrapper preserves the existing validation, CAS, and application
/// problem wire formats while giving producers and consumers one authority.
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(untagged, deny_unknown_fields)]
pub enum DashboardConfigurationRefusalV1 {
Validation {
detail: String,
validation_errors: Vec<SettingsValidationIssueV1>,
},
Configuration(ConfigurationRefusalV1),
ApplicationProblem(ApplicationProblemEnvelope),
}

/// Axum-compatible typed error used by dashboard configuration handlers.
pub type DashboardConfigurationRouteErrorV1 = (StatusCode, Json<Value>);
pub type DashboardConfigurationRouteErrorV1 = (StatusCode, Json<DashboardConfigurationRefusalV1>);

/// Parse a project settings patch while preserving the dashboard's established
/// malformed-payload response shape.
Expand Down Expand Up @@ -175,13 +208,15 @@ pub fn validate_code_index_worker_settings_patch(
}

/// Render validation failures using the generated dashboard wire shape.
pub fn settings_validation_error(errors: impl Serialize) -> DashboardConfigurationRouteErrorV1 {
pub fn settings_validation_error(
errors: impl IntoIterator<Item = SettingsValidationIssueV1>,
) -> DashboardConfigurationRouteErrorV1 {
(
StatusCode::BAD_REQUEST,
Json(json!({
"detail": "settings validation failed",
"validation_errors": errors,
})),
Json(DashboardConfigurationRefusalV1::Validation {
detail: "settings validation failed".to_owned(),
validation_errors: errors.into_iter().collect(),
}),
)
}

Expand All @@ -193,23 +228,25 @@ pub fn configuration_revision_conflict_error(
) -> DashboardConfigurationRouteErrorV1 {
(
StatusCode::CONFLICT,
Json(json!({
"code": "configuration_revision_conflict",
"detail": detail,
"expected_revision_id": expected,
"actual_revision_id": actual,
})),
Json(DashboardConfigurationRefusalV1::Configuration(
ConfigurationRefusalV1::ConfigurationRevisionConflict {
detail: detail.to_owned(),
expected_revision_id: expected.to_owned(),
actual_revision_id: actual.to_owned(),
},
)),
)
}

/// Render the fail-closed missing-authority response.
pub fn configuration_authority_unavailable_error() -> DashboardConfigurationRouteErrorV1 {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({
"code": "configuration_authority_unavailable",
"detail": "configuration authority is unavailable",
})),
Json(DashboardConfigurationRefusalV1::Configuration(
ConfigurationRefusalV1::ConfigurationAuthorityUnavailable {
detail: "configuration authority is unavailable".to_owned(),
},
)),
)
}

Expand All @@ -225,24 +262,28 @@ pub fn configuration_application_problem_error(
} else {
crate::application_problem_status(problem.problem.kind)
};
let payload = serde_json::to_value(problem)
.unwrap_or_else(|_| json!({ "detail": "configuration mutation was rejected" }));
(status, Json(payload))
(
status,
Json(DashboardConfigurationRefusalV1::ApplicationProblem(problem)),
)
}

fn validation_error(field: &str, message: &str) -> Value {
json!({ "field": field, "message": message })
fn validation_error(field: &str, message: &str) -> SettingsValidationIssueV1 {
SettingsValidationIssueV1 {
field: field.to_owned(),
message: message.to_owned(),
}
}

fn patch_shape_error(scope: &str, error: &serde_json::Error) -> DashboardConfigurationRouteErrorV1 {
let message = format!("invalid {scope} patch: {error}");
let field = serde_error_field(&message).unwrap_or_else(|| "patch".to_owned());
(
StatusCode::BAD_REQUEST,
Json(json!({
"detail": message,
"validation_errors": [{ "field": field, "message": message }],
})),
Json(DashboardConfigurationRefusalV1::Validation {
detail: message.clone(),
validation_errors: vec![SettingsValidationIssueV1 { field, message }],
}),
)
}

Expand All @@ -260,6 +301,7 @@ fn serde_error_field(message: &str) -> Option<String> {
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;

#[test]
fn user_worker_patch_round_trips_the_tagged_contract() {
Expand Down Expand Up @@ -298,7 +340,10 @@ mod tests {
let (status, Json(body)) =
validate_code_index_worker_settings_patch(&patch).expect_err("zero must be denied");
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["validation_errors"][0]["field"], "code_index_workers");
assert_eq!(
serde_json::to_value(body).unwrap()["validation_errors"][0]["field"],
"code_index_workers"
);
}

#[test]
Expand All @@ -324,7 +369,10 @@ mod tests {
let (status, Json(body)) = admit_user_settings_patch(&invalid, |_| None)
.expect_err("zero duration must be denied");
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["validation_errors"][0]["field"], "watcher_debounce");
assert_eq!(
serde_json::to_value(body).unwrap()["validation_errors"][0]["field"],
"watcher_debounce"
);
}

#[test]
Expand All @@ -338,6 +386,9 @@ mod tests {

let (status, Json(body)) = result.expect_err("mixed authority must be rejected");
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["validation_errors"][0]["field"], "code_index_workers");
assert_eq!(
serde_json::to_value(body).unwrap()["validation_errors"][0]["field"],
"code_index_workers"
);
}
}
23 changes: 18 additions & 5 deletions crates/tracedecay-api/tests/dashboard_presentations.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use axum::http::StatusCode;
use serde_json::json;
use tracedecay_api::configuration::{
configuration_revision_conflict_error, parse_project_settings_patch, parse_user_settings_patch,
ConfigurationRefusalV1, DashboardConfigurationRefusalV1, configuration_revision_conflict_error,
parse_project_settings_patch, parse_user_settings_patch,
};
use tracedecay_api::feedback::{
FeedbackStatusCoverageV1, FeedbackStatusDenominatorsV1, FeedbackStatusPresentationV1,
Expand Down Expand Up @@ -47,17 +48,29 @@ fn configuration_patch_and_cas_errors_are_api_owned() {
)
.expect_err("unknown field must remain a typed bad request");
assert_eq!(shape_error.0, StatusCode::BAD_REQUEST);
assert_eq!(shape_error.1.0["validation_errors"][0]["field"], "unknown");
assert!(matches!(
&shape_error.1.0,
DashboardConfigurationRefusalV1::Validation { .. }
));
let shape_body = serde_json::to_value(shape_error.1.0).expect("serialize validation refusal");
assert_eq!(shape_body["validation_errors"][0]["field"], "unknown");

let conflict = configuration_revision_conflict_error(
"settings changed after this edit began; refresh and retry",
"revision.expected",
"revision.actual",
);
assert_eq!(conflict.0, StatusCode::CONFLICT);
assert_eq!(conflict.1.0["code"], "configuration_revision_conflict");
assert_eq!(conflict.1.0["expected_revision_id"], "revision.expected");
assert_eq!(conflict.1.0["actual_revision_id"], "revision.actual");
assert!(matches!(
&conflict.1.0,
DashboardConfigurationRefusalV1::Configuration(
ConfigurationRefusalV1::ConfigurationRevisionConflict { .. }
)
));
let conflict_body = serde_json::to_value(conflict.1.0).expect("serialize revision refusal");
assert_eq!(conflict_body["code"], "configuration_revision_conflict");
assert_eq!(conflict_body["expected_revision_id"], "revision.expected");
assert_eq!(conflict_body["actual_revision_id"], "revision.actual");
}

#[test]
Expand Down
2 changes: 1 addition & 1 deletion crates/tracedecay-contracts/src/result/envelope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ impl ResultContractRef {
}

/// Canonical outcome family for an admitted application operation.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case", tag = "outcome", content = "value")]
pub enum ApplicationOutcome<T> {
Evidence(EvidencePacket<T>),
Expand Down
33 changes: 20 additions & 13 deletions crates/tracedecay-contracts/src/result/evidence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ use crate::identity::application_identifier;
use super::{CancellationObservation, OperationBudgetUsage, OperationReceipt, ResultContractRef};

application_identifier!(
@no_schema
EvidenceIdentity => ("evidence identity", 512),
ScoreId => ("score id", 512),
// Existing authenticated query cursors bind typed scope, access, key,
Expand All @@ -26,7 +25,7 @@ application_identifier!(

/// Exact continuation authority for the enclosing evidence page: an opaque
/// cursor bound to the operation and request that minted it.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PageCursor {
Opaque { cursor: OpaqueCursor },
Expand All @@ -47,7 +46,9 @@ impl From<OpaqueCursor> for PageCursor {
}

/// Application-level freshness. Missing or partial truth never becomes current.
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[derive(
Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord, Hash,
)]
#[serde(rename_all = "snake_case")]
pub enum FreshnessState {
Current,
Expand All @@ -56,7 +57,7 @@ pub enum FreshnessState {
}

/// Temporal provenance for a packet.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct TemporalState {
pub requested_mode: TemporalModeV1,
Expand Down Expand Up @@ -363,7 +364,9 @@ pub struct Omission {
}

/// Evidence score semantics. Scores are metadata, never authority.
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[derive(
Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord, Hash,
)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceScoreKind {
OrdinalRank,
Expand All @@ -372,7 +375,7 @@ pub enum EvidenceScoreKind {
CalibratedInterval,
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum EvidenceScoreValue {
Ordinal {
Expand All @@ -387,7 +390,7 @@ pub enum EvidenceScoreValue {
},
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct EvidenceScore {
pub score_id: ScoreId,
Expand All @@ -399,7 +402,7 @@ pub struct EvidenceScore {
}

/// Evidence authority is separate from caller authority and cannot grant access.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct EvidenceAuthority {
pub evidence_id: EvidenceIdentity,
Expand All @@ -411,7 +414,9 @@ pub struct EvidenceAuthority {
}

/// Bounded elapsed-work classification supplied by a retriever.
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[derive(
Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord, Hash,
)]
#[serde(rename_all = "snake_case")]
pub enum BudgetClass {
WithinBudget,
Expand All @@ -420,7 +425,9 @@ pub enum BudgetClass {
}

/// Terminal state reported by one retriever contribution.
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[derive(
Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord, Hash,
)]
#[serde(rename_all = "snake_case")]
pub enum RetrieverContributionState {
Completed,
Expand All @@ -434,7 +441,7 @@ pub enum RetrieverContributionState {
}

/// One source-owned contribution to the packet fold.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RetrieverContribution {
pub retriever_id: RetrieverId,
Expand All @@ -453,7 +460,7 @@ pub struct RetrieverContribution {

/// Stable page state. General cursor bytes stay opaque until authorization is
/// revalidated; structural fact cursors retain their canonical ordering type.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PageState {
pub sort_contract_id: SortContractId,
Expand Down Expand Up @@ -510,7 +517,7 @@ pub struct RetrievalEvidence<T> {
}

/// Immutable evidence packet consumed by adapters and later planner work.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct EvidencePacket<T> {
pub temporal: TemporalState,
Expand Down
2 changes: 1 addition & 1 deletion crates/tracedecay-contracts/src/result/receipt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ pub enum ReconciliationState {
}

/// Read-only preview of a future typed effect.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PreviewResult<T> {
pub preview_id: PreviewId,
Expand Down
3 changes: 2 additions & 1 deletion crates/tracedecay-contracts/src/settings_preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
//! operation. This module owns only project patch validation shared before
//! adapters invoke that operation.

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

/// One field-level settings validation failure.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct SettingsValidationIssueV1 {
pub field: String,
pub message: String,
Expand Down
Loading
Loading