feat(ticket): decouple audited lifecycle decisions from MR integration

T-718: unify state, completion, close and reopen with explicit CAS and operation receipts; keep MR integration proofs independent. Queue only the explicitly selected Ticket and fence target edits against active Worker grants. Align REST, Worker/WIP, evidence projection and generated contracts.

Validation: root cargo check; full ticket, merge-request, client, worker, yoi-workspace-server and server-api tests; server-api typescript feature; OpenAPI and six generated API checkers; Svelte check and 36 narrow Web tests; fmt and diff checks.
This commit is contained in:
2026-10-08 19:12:39 +09:00
parent e939cd4e4b
commit df390f02e9
25 changed files with 4596 additions and 2292 deletions
+75 -37
View File
@@ -324,7 +324,6 @@ impl_openapi_schema!(
SubmitMergeRequestReviewRequest,
TextResponse,
TicketCloseRecordRequest,
TicketCompletionEvent,
TicketDependencyCheckResponse,
TicketDetail,
TicketDoctorResponse,
@@ -350,6 +349,7 @@ impl_openapi_schema!(
TicketRoleAssignmentsResponse,
TicketShowRequest,
TicketStateChangeRequest,
TicketStateUpdateRequest,
TicketSummarySearchQuery,
TicketThreadEventRequest,
RuntimeManagementApiError,
@@ -2917,7 +2917,7 @@ pub trait ServerApi {
#[path] id: String,
#[body] request: TicketThreadEventRequest,
) -> Result<(), RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/state-changes", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
#[post("/api/w/{workspace_id}/tickets/{id}/state-changes", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_state_change_add(
&self,
#[extension] context: ServerRequestContext,
@@ -2933,7 +2933,7 @@ pub trait ServerApi {
#[path] id: String,
#[body] request: TicketIntakeSummaryRequest,
) -> Result<(), RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/state-fields/{field}", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
#[post("/api/w/{workspace_id}/tickets/{id}/state-fields/{field}", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_state_field_set(
&self,
#[extension] context: ServerRequestContext,
@@ -2942,7 +2942,7 @@ pub trait ServerApi {
#[path] field: String,
#[body] request: TicketStateChangeRequest,
) -> Result<(), RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/workflow-state", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
#[post("/api/w/{workspace_id}/tickets/{id}/workflow-state", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_workflow_state_set(
&self,
#[extension] context: ServerRequestContext,
@@ -2950,6 +2950,14 @@ pub trait ServerApi {
#[path] id: String,
#[body] request: TicketStateChangeRequest,
) -> Result<(), RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/state-update", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_state_update(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[body] request: TicketStateUpdateRequest,
) -> Result<TicketRecord, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/workflow/mark-ready", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_mark_ready_record(
&self,
@@ -3047,15 +3055,15 @@ pub trait ServerApi {
#[path] id: String,
#[body] request: CompleteMergeRequestRequest,
) -> Result<MergeEvent, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/complete", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)]
#[post("/api/w/{workspace_id}/tickets/{id}/complete", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_complete(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[body] request: CompleteTicketRequest,
) -> Result<TicketCompletionEvent, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/workflow/close", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
) -> Result<TicketRecord, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/workflow/close", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_close_record(
&self,
#[extension] context: ServerRequestContext,
@@ -3150,9 +3158,10 @@ pub trait ServerApi {
#[body] request: CancelTicketImplementationRequest,
) -> Result<TicketDetail, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/state", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
#[post("/api/w/{workspace_id}/tickets/{id}/state", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_state_transition(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[body] request: BrowserTransitionTicketStateRequest,
@@ -3161,6 +3170,7 @@ pub trait ServerApi {
#[post("/api/w/{workspace_id}/tickets/{id}/ready", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_ready(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[body] request: TicketMarkReadyRequest,
@@ -3177,14 +3187,16 @@ pub trait ServerApi {
#[post("/api/w/{workspace_id}/tickets/{id}/queue", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_queue(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[body] request: BrowserQueueTicketRequest,
) -> Result<TicketQueueResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/close", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
#[post("/api/w/{workspace_id}/tickets/{id}/close", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_close(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[body] request: BrowserCloseTicketRequest,
@@ -7647,9 +7659,10 @@ transparent_ticket_dto!(
transparent_ticket_dto!(CreateTicketRecordRequest, ticket::NewTicket);
transparent_ticket_dto!(EditTicketRecordItemRequest, ticket::TicketItemEdit);
transparent_ticket_dto!(TicketThreadEventRequest, ticket::NewTicketEvent);
transparent_ticket_dto!(TicketStateChangeRequest, ticket::TicketStateChange);
transparent_ticket_dto!(TicketStateChangeRequest, ticket::TicketStateUpdate);
transparent_ticket_dto!(TicketStateUpdateRequest, ticket::TicketStateUpdate);
transparent_ticket_dto!(TicketIntakeSummaryRequest, ticket::TicketIntakeSummary);
transparent_ticket_dto!(TicketCloseRecordRequest, ticket::MarkdownText);
transparent_ticket_dto!(TicketCloseRecordRequest, ticket::TicketCompletion);
transparent_ticket_dto!(CreateTicketRelationRequest, ticket::NewTicketRelation);
transparent_ticket_dto!(
CreateTicketOrchestrationPlanRequest,
@@ -7774,9 +7787,11 @@ pub struct BrowserEditTicketRequest {
#[serde(deny_unknown_fields)]
pub struct BrowserTransitionTicketStateRequest {
pub state: BrowserTicketWorkflowState,
pub reason: Option<String>,
pub operation_key: String,
pub expected_item_revision: String,
pub expected_state: BrowserTicketWorkflowState,
pub reason: String,
pub body: Option<String>,
pub author: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -7807,6 +7822,9 @@ pub struct BrowserQueueTicketRequest {}
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct BrowserCloseTicketRequest {
pub operation_key: String,
pub expected_item_revision: String,
pub expected_state: BrowserTicketWorkflowState,
pub resolution: String,
}
@@ -8185,22 +8203,12 @@ pub struct CompleteMergeRequestRequest {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CompleteTicketRequest {
pub operation_id: String,
pub item_revision: String,
pub merge_request_ids: Vec<String>,
pub requirement_approval_event_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TicketCompletionEvent {
pub operation_id: String,
pub ticket_id: String,
pub item_revision: String,
pub merge_request_ids: Vec<String>,
pub requirement_approval_event_id: String,
pub completed_by: MergeRequestWorkerIdentity,
pub created_at: String,
pub operation_key: String,
pub expected_item_revision: String,
pub expected_state: ticket::TicketWorkflowState,
pub reason: String,
#[serde(default)]
pub references: Vec<ticket::TicketReference>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
@@ -11461,13 +11469,6 @@ mod tests {
#[test]
fn ticket_objective_and_merge_request_operations_are_in_server_api_metadata() {
let operations = ServerApiMetadata::OPERATIONS;
assert_eq!(
operations
.iter()
.filter(|operation| operation.operation_id.starts_with("ticket_"))
.count(),
38
);
assert_eq!(
operations
.iter()
@@ -11483,6 +11484,16 @@ mod tests {
11
);
for (operation_id, method, path) in [
(
"ticket_state_update",
HttpMethod::Post,
"/api/w/{workspace_id}/tickets/{id}/state-update",
),
(
"ticket_complete",
HttpMethod::Post,
"/api/w/{workspace_id}/tickets/{id}/complete",
),
(
"ticket_relation_query",
HttpMethod::Post,
@@ -12540,6 +12551,34 @@ mod tests {
assert!(serde_json::from_value::<RuntimeConnectionTestResponse>(unknown).is_err());
}
#[test]
fn ticket_completion_wire_requires_cas_and_judgment_not_merge_evidence() {
let request = serde_json::json!({
"operation_key": "investigation-done",
"expected_item_revision": "ticket:1",
"expected_state": "planning",
"reason": "Question answered in the thread"
});
let parsed: CompleteTicketRequest = serde_json::from_value(request.clone()).unwrap();
assert!(parsed.references.is_empty());
for field in [
"operation_key",
"expected_item_revision",
"expected_state",
"reason",
] {
let mut missing = request.clone();
missing.as_object_mut().unwrap().remove(field);
assert!(
serde_json::from_value::<CompleteTicketRequest>(missing).is_err(),
"{field}"
);
}
let mut old = request;
old["requirement_approval_event_id"] = serde_json::json!("not-a-judgment");
assert!(serde_json::from_value::<CompleteTicketRequest>(old).is_err());
}
#[cfg(feature = "typescript")]
#[test]
fn generated_catalog_typescript_keeps_public_wrappers_and_nullability() {
@@ -13388,7 +13427,6 @@ mod openapi_artifact_tests {
"merge_request_review_revoke",
"merge_request_review_submit",
"merge_request_reviewer_child_register",
"ticket_complete",
"runtime_resource_fetch",
"worker_control_cancel",
"worker_control_input",