diff --git a/README.md b/README.md index a58b7ff..51427ec 100644 --- a/README.md +++ b/README.md @@ -422,10 +422,8 @@ cannot be normalized (relative paths, `..` past the root) are denied outright. ```mermaid stateDiagram-v2 - [*] --> Draft - Draft --> AwaitingApproval - Draft --> Ready - Draft --> Cancelled + [*] --> AwaitingApproval + [*] --> Ready AwaitingApproval --> Ready AwaitingApproval --> Cancelled Ready --> Running @@ -448,6 +446,8 @@ stateDiagram-v2 Key invariants: +- **the only entry states are `AwaitingApproval` and `Ready`** — creation evaluates the whole + plan and picks one of them, and nothing reaches them from outside; - **`Running` cannot jump straight to `Succeeded`** — it must pass through `Verifying`; - `Failed` is terminal but keeps one outgoing edge, `Failed → Compensating`, so recovery semantics can reopen it; @@ -519,8 +519,8 @@ returns 401 `unauthorized` otherwise. See [Local authentication](#local-authenti |---|---|---| | `GET` | `/healthz` | Service status, API version, and the running security posture: `authentication` (always `bearer_token`) and `capability_admission` (`unsigned_allowed` or `require_signed`) | | `POST` | `/v1/tasks` | Validate and create a task | -| `GET` | `/v1/tasks` | List tasks as `{"tasks": [...], "warnings": [...]}`; a corrupt record file is skipped and reported in `warnings` instead of failing the whole listing | -| `GET` | `/v1/tasks/{id}` | Read one task | +| `GET` | `/v1/tasks` | List task **summaries** as `{"tasks": [...], "warnings": [...]}` — id, state, revision, intent, timestamps, and counts, with **no event bodies**; a corrupt record file is skipped and reported in `warnings` instead of failing the whole listing | +| `GET` | `/v1/tasks/{id}` | Read one task; returns the **50 most recent** events plus `event_count` (the true total). `?events=` asks for more, clamped to a hard maximum of 1000 | | `POST` | `/v1/tasks/{id}/capabilities` | Grant capabilities to an existing task; each new capability must have `issued_to == plan.task_id` and be currently active; takes `expected_revision` | | `POST` | `/v1/tasks/{id}/outcomes` | Record one action's execution outcome and evidence; appends an `outcome_recorded` event and bumps the revision. Allowed only while `Running`/`Verifying`, at most one per action (append-only), and the action must belong to the plan | | `POST` | `/v1/tasks/{id}/evaluate` | Evaluate without executing; isolation is resolved **per action**, and the result is appended as an `evaluated` event, bumping the revision | diff --git a/README.zh-CN.md b/README.zh-CN.md index ec4c6e0..f2731d7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -402,10 +402,8 @@ Capability 是资源范围化的权限,**独立过期,且从不存放任何 ```mermaid stateDiagram-v2 - [*] --> Draft - Draft --> AwaitingApproval - Draft --> Ready - Draft --> Cancelled + [*] --> AwaitingApproval + [*] --> Ready AwaitingApproval --> Ready AwaitingApproval --> Cancelled Ready --> Running @@ -428,6 +426,8 @@ stateDiagram-v2 关键不变式: +- **入口状态只有 `AwaitingApproval` 与 `Ready`**——创建时对整盘计划求值后二选一,外部无法进入 + 其他状态; - **`Running` 不能直接跳到 `Succeeded`**——必须经过 `Verifying`; - `Failed` 是终态,但保留唯一一条出边 `Failed → Compensating`,供恢复语义重新打开; - 两条授权敏感的边额外做策略复检: @@ -492,8 +492,8 @@ stateDiagram-v2 |---|---|---| | `GET` | `/healthz` | 服务状态、API 版本,以及当前安全姿态:`authentication`(恒为 `bearer_token`)与 `capability_admission`(`unsigned_allowed` / `require_signed`) | | `POST` | `/v1/tasks` | 校验并创建任务 | -| `GET` | `/v1/tasks` | 列出任务,响应为 `{"tasks": [...], "warnings": [...]}`;损坏的记录文件被跳过并记入 `warnings`,不会让整个列表失败 | -| `GET` | `/v1/tasks/{id}` | 读取任务 | +| `GET` | `/v1/tasks` | 列出任务**摘要**,响应为 `{"tasks": [...], "warnings": [...]}`:id、状态、revision、intent、时间戳与各类计数,**不含事件体**;损坏的记录文件被跳过并记入 `warnings`,不会让整个列表失败 | +| `GET` | `/v1/tasks/{id}` | 读取单个任务;默认返回**最近 50 条**事件与总数 `event_count`,`?events=` 可索取更多,硬上限 1000 | | `POST` | `/v1/tasks/{id}/capabilities` | 给已存在任务补授权;每个新 capability 必须 `issued_to == plan.task_id` 且当前有效;带 `expected_revision` | | `POST` | `/v1/tasks/{id}/outcomes` | 记录单个 action 的执行结果与证据;追加 `outcome_recorded` 事件并使 revision +1。只允许在 `Running`/`Verifying` 记录,每 action 至多一条(append-only),且该 action 必须属于该计划 | | `POST` | `/v1/tasks/{id}/evaluate` | 评估、不执行;**逐 action** 解析隔离等级,结果作为 `evaluated` 事件追加并使 revision +1 | diff --git a/crates/andromeda-core/src/task.rs b/crates/andromeda-core/src/task.rs index 93802c0..f42e770 100644 --- a/crates/andromeda-core/src/task.rs +++ b/crates/andromeda-core/src/task.rs @@ -59,10 +59,16 @@ impl Intent { } /// Durable task lifecycle. Every transition is checked by deterministic code. +/// +/// The only entry states are `AwaitingApproval` and `Ready`: task creation +/// runs the policy engine over the whole plan and picks one of them, and no +/// edge leads back into either from outside. There is deliberately no `Draft` +/// state — one existed, produced by nothing and reachable by no edge, and a +/// state a security-relevant machine can never be in is contract noise that +/// still has to be handled by every client that reads `state`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TaskState { - Draft, AwaitingApproval, Ready, Running, @@ -98,10 +104,7 @@ impl TaskState { pub fn transition(self, to: Self) -> Result { let allowed = matches!( (self, to), - ( - Self::Draft, - Self::AwaitingApproval | Self::Ready | Self::Cancelled - ) | (Self::AwaitingApproval, Self::Ready | Self::Cancelled) + (Self::AwaitingApproval, Self::Ready | Self::Cancelled) | (Self::Ready, Self::Running | Self::Cancelled) | ( Self::Running, @@ -143,9 +146,89 @@ impl TaskState { mod tests { use super::*; + /// Every state, at its own index. Kept in one place so the matrix test + /// below cannot quietly stop covering part of the machine. + const ALL_STATES: [TaskState; 10] = [ + TaskState::AwaitingApproval, + TaskState::Ready, + TaskState::Running, + TaskState::Verifying, + TaskState::Succeeded, + TaskState::Failed, + TaskState::Cancelling, + TaskState::Cancelled, + TaskState::Compensating, + TaskState::Compensated, + ]; + + /// Proves `ALL_STATES` lists every state exactly once: the `match` is + /// exhaustive, so a new variant forces a new arm (and a longer array), + /// and each element must sit at the index its own variant names. + #[test] + fn the_state_list_covers_the_whole_machine() { + for (position, state) in ALL_STATES.into_iter().enumerate() { + let index = match state { + TaskState::AwaitingApproval => 0, + TaskState::Ready => 1, + TaskState::Running => 2, + TaskState::Verifying => 3, + TaskState::Succeeded => 4, + TaskState::Failed => 5, + TaskState::Cancelling => 6, + TaskState::Cancelled => 7, + TaskState::Compensating => 8, + TaskState::Compensated => 9, + }; + assert_eq!( + position, index, + "ALL_STATES must list {state:?} exactly once, in order" + ); + } + } + + /// The whole transition relation, pinned edge by edge over every ordered + /// pair of states. An edge added, removed, or widened anywhere in + /// [`TaskState::transition`] shows up here as a named failure. + #[test] + fn the_transition_matrix_is_pinned() { + const ALLOWED: [(TaskState, TaskState); 15] = [ + // Creation lands in one of the two entry states; approval (or a + // late grant) is the only way forward from AwaitingApproval. + (TaskState::AwaitingApproval, TaskState::Ready), + (TaskState::AwaitingApproval, TaskState::Cancelled), + (TaskState::Ready, TaskState::Running), + (TaskState::Ready, TaskState::Cancelled), + (TaskState::Running, TaskState::Verifying), + (TaskState::Running, TaskState::Failed), + (TaskState::Running, TaskState::Cancelling), + (TaskState::Verifying, TaskState::Succeeded), + (TaskState::Verifying, TaskState::Failed), + (TaskState::Verifying, TaskState::Cancelling), + // The one outgoing edge of a terminal state: recovery reopens a + // failure when the plan asks for compensation. + (TaskState::Failed, TaskState::Compensating), + (TaskState::Cancelling, TaskState::Cancelled), + (TaskState::Cancelling, TaskState::Compensating), + (TaskState::Compensating, TaskState::Compensated), + (TaskState::Compensating, TaskState::Failed), + ]; + + for from in ALL_STATES { + for to in ALL_STATES { + let expected = ALLOWED.contains(&(from, to)); + assert_eq!( + from.transition(to).is_ok(), + expected, + "{from:?} -> {to:?} must be {}", + if expected { "allowed" } else { "rejected" } + ); + } + } + } + #[test] fn successful_lifecycle_is_valid() { - let state = TaskState::Draft + let state = TaskState::AwaitingApproval .transition(TaskState::Ready) .and_then(|state| state.transition(TaskState::Running)) .and_then(|state| state.transition(TaskState::Verifying)) diff --git a/crates/andromeda-runtime/src/store.rs b/crates/andromeda-runtime/src/store.rs index 3262681..3e005c8 100644 --- a/crates/andromeda-runtime/src/store.rs +++ b/crates/andromeda-runtime/src/store.rs @@ -422,7 +422,7 @@ mod tests { fn record() -> TaskRecord { TaskRecord { plan: ActionPlan::new(Intent::new("store test", "test"), Vec::new()), - state: TaskState::Draft, + state: TaskState::AwaitingApproval, revision: 0, capabilities: Vec::new(), events: Vec::new(), diff --git a/crates/andromeda-taskd/Cargo.toml b/crates/andromeda-taskd/Cargo.toml index fce02a0..1326902 100644 --- a/crates/andromeda-taskd/Cargo.toml +++ b/crates/andromeda-taskd/Cargo.toml @@ -12,6 +12,10 @@ andromeda-core.workspace = true andromeda-policy.workspace = true andromeda-runtime.workspace = true axum.workspace = true +# Timestamps on the wire DTOs (`src/wire.rs`). Already a dev-dependency here +# and a normal dependency of `andromeda-core`/`-runtime`, so promoting it adds +# no new crate to the lockfile. +chrono.workspace = true clap.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/andromeda-taskd/src/lib.rs b/crates/andromeda-taskd/src/lib.rs index 6b40c64..476a6f2 100644 --- a/crates/andromeda-taskd/src/lib.rs +++ b/crates/andromeda-taskd/src/lib.rs @@ -1,6 +1,7 @@ //! Local HTTP API for the Andromeda task control plane. pub mod auth; +mod wire; use std::net::SocketAddr; use std::str::FromStr; @@ -11,15 +12,18 @@ use andromeda_runtime::{ CreateTaskRequest, EvaluationRequest, GrantCapabilitiesRequest, RecordOutcomeRequest, ServiceError, StateTransitionRequest, StoreError, TaskService, TransitionGuardError, }; -use axum::extract::{Path, Request, State}; +use axum::extract::rejection::QueryRejection; +use axum::extract::{Path, Query, Request, State}; use axum::http::{StatusCode, header}; use axum::middleware::{self, Next}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; +use serde::Deserialize; use serde_json::{Value, json}; pub use auth::{AuthError, Authenticator}; +use wire::{EvaluationReportView, TaskListingView, TaskView}; #[derive(Debug, Clone)] struct AppState { @@ -244,24 +248,46 @@ async fn create_task( Json(request): Json, ) -> Result<(StatusCode, Json), ApiError> { let record = run_blocking(&state, move |service| service.create(request)).await?; - Ok((StatusCode::CREATED, Json(serde_json::to_value(record)?))) + Ok(( + StatusCode::CREATED, + Json(serde_json::to_value(TaskView::from(&record))?), + )) } async fn list_tasks(State(state): State) -> Result, ApiError> { let listing = run_blocking(&state, TaskService::list_detailed).await?; - Ok(Json(json!({ - "tasks": serde_json::to_value(listing.records)?, - "warnings": serde_json::to_value(listing.warnings)?, - }))) + Ok(Json(serde_json::to_value(TaskListingView::from(&listing))?)) +} + +/// Query string of `GET /v1/tasks/{id}`. +/// +/// `deny_unknown_fields` on purpose: a mistyped parameter (`?event=200`) must +/// fail loudly rather than be dropped and quietly answered with the default, +/// which is the same silent-widening failure the capability review flagged for +/// request bodies. +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct TaskReadQuery { + /// How many of the most recent events to return. Defaults to + /// [`wire::DEFAULT_EVENT_LIMIT`] and is clamped to + /// [`wire::MAX_EVENT_LIMIT`]; `event_count` in the response always reports + /// the true total, so truncation is observable. + #[serde(default)] + events: Option, } async fn get_task( State(state): State, Path(task_id): Path, + query: Result, QueryRejection>, ) -> Result, ApiError> { let task_id = parse_task_id(&task_id)?; + let Query(query) = query.map_err(|error| ApiError::BadRequest(error.body_text()))?; + let limit = query.events.unwrap_or(wire::DEFAULT_EVENT_LIMIT); let record = run_blocking(&state, move |service| service.get(task_id)).await?; - Ok(Json(serde_json::to_value(record)?)) + Ok(Json(serde_json::to_value(TaskView::bounded( + &record, limit, + ))?)) } async fn grant_capabilities( @@ -274,7 +300,7 @@ async fn grant_capabilities( service.grant_capabilities(task_id, request) }) .await?; - Ok(Json(serde_json::to_value(record)?)) + Ok(Json(serde_json::to_value(TaskView::from(&record))?)) } async fn record_outcome( @@ -287,7 +313,7 @@ async fn record_outcome( service.record_outcome(task_id, request) }) .await?; - Ok(Json(serde_json::to_value(record)?)) + Ok(Json(serde_json::to_value(TaskView::from(&record))?)) } async fn evaluate_task( @@ -297,7 +323,9 @@ async fn evaluate_task( ) -> Result, ApiError> { let task_id = parse_task_id(&task_id)?; let report = run_blocking(&state, move |service| service.evaluate(task_id, &request)).await?; - Ok(Json(serde_json::to_value(report)?)) + Ok(Json(serde_json::to_value(EvaluationReportView::from( + &report, + ))?)) } async fn transition_task( @@ -307,7 +335,7 @@ async fn transition_task( ) -> Result, ApiError> { let task_id = parse_task_id(&task_id)?; let record = run_blocking(&state, move |service| service.transition(task_id, request)).await?; - Ok(Json(serde_json::to_value(record)?)) + Ok(Json(serde_json::to_value(TaskView::from(&record))?)) } fn parse_task_id(value: &str) -> Result { @@ -776,6 +804,147 @@ mod tests { assert_eq!(events.last().expect("event")["kind"]["type"], "evaluated"); } + /// Evaluates `task_id` `count` times, appending one `evaluated` event per + /// call — the cheapest way to grow a real event history over HTTP. + async fn grow_event_history(app: &Router, task_id: TaskId, count: usize) { + let evaluation = EvaluationRequest::default(); + for _ in 0..count { + let (status, body) = send( + app, + local_request( + "POST", + &format!("/v1/tasks/{task_id}/evaluate"), + Some(&evaluation), + ), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + } + } + + /// A single-task read returns the most recent events and the true total, + /// and a caller can ask for more up to the ceiling. + #[tokio::test] + async fn get_bounds_the_event_history_and_reports_the_total() { + let temp = TempDir::new().expect("tempdir"); + let app = test_app(&temp); + let request = inspection_request(workspace_path()); + let task_id = request.plan.task_id; + send(&app, local_request("POST", "/v1/tasks", Some(&request))).await; + + let evaluations = wire::DEFAULT_EVENT_LIMIT + 10; + grow_event_history(&app, task_id, evaluations).await; + // One `created` event plus one `evaluated` event per evaluation. + let total = evaluations + 1; + + let (status, fetched) = send( + &app, + local_request("GET", &format!("/v1/tasks/{task_id}"), None::<&Value>), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + fetched["events"].as_array().expect("events").len(), + wire::DEFAULT_EVENT_LIMIT, + "the default read must be bounded" + ); + assert_eq!(fetched["event_count"], json!(total)); + // The window is the newest end of the history, not the oldest. + assert_eq!(fetched["events"][0]["kind"]["type"], "evaluated"); + + // An explicit request widens the window up to the ceiling. + for (query, expected) in [ + (format!("?events={total}"), total), + ("?events=0".to_owned(), 0), + (format!("?events={}", wire::MAX_EVENT_LIMIT + 1), total), + ] { + let (status, fetched) = send( + &app, + local_request( + "GET", + &format!("/v1/tasks/{task_id}{query}"), + None::<&Value>, + ), + ) + .await; + assert_eq!(status, StatusCode::OK, "{query}"); + assert_eq!( + fetched["events"].as_array().expect("events").len(), + expected, + "{query}" + ); + assert_eq!(fetched["event_count"], json!(total), "{query}"); + } + + // A mistyped parameter is refused, not silently answered with the + // default — the same rule the request bodies follow. + let (status, error) = send( + &app, + local_request( + "GET", + &format!("/v1/tasks/{task_id}?event=5"), + None::<&Value>, + ), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(error["error"], "bad_request"); + } + + /// The listing carries no event bodies at all, so its size no longer grows + /// with the history of the tasks it lists. + #[tokio::test] + async fn list_returns_summaries_instead_of_full_records() { + let temp = TempDir::new().expect("tempdir"); + let app = test_app(&temp); + let request = inspection_request(workspace_path()); + let task_id = request.plan.task_id; + send(&app, local_request("POST", "/v1/tasks", Some(&request))).await; + // Enough history to be visibly expensive without making the test slow; + // `wire::tests::the_listing_projection_is_constant_in_the_event_count` + // measures the same projection at a thousand events, in memory. + let evaluations = wire::DEFAULT_EVENT_LIMIT; + grow_event_history(&app, task_id, evaluations).await; + + let (status, listing) = send(&app, local_request("GET", "/v1/tasks", None::<&Value>)).await; + assert_eq!(status, StatusCode::OK); + let tasks = listing["tasks"].as_array().expect("tasks"); + assert_eq!(tasks.len(), 1); + let summary = &tasks[0]; + assert!( + summary.get("events").is_none() && summary.get("plan").is_none(), + "the listing must not carry event bodies: {summary}" + ); + assert_eq!(summary["task_id"], json!(task_id.to_string())); + assert_eq!(summary["state"], "ready"); + assert_eq!(summary["revision"], json!(evaluations)); + assert_eq!(summary["event_count"], json!(evaluations + 1)); + assert_eq!(listing["warnings"].as_array().expect("warnings").len(), 0); + + // Measured, not asserted: compare the listing against what the same + // endpoint used to return — the complete record for every task. + let (_, full) = send( + &app, + local_request( + "GET", + &format!("/v1/tasks/{task_id}?events={}", wire::MAX_EVENT_LIMIT), + None::<&Value>, + ), + ) + .await; + let before = serde_json::to_vec(&json!({"tasks": [full], "warnings": []})) + .expect("body") + .len(); + let after = serde_json::to_vec(&listing).expect("body").len(); + eprintln!( + "GET /v1/tasks with {evaluations} evaluations: {before} B before, {after} B after" + ); + assert!( + before > after * 20, + "expected a large reduction, got {before} B -> {after} B" + ); + } + #[tokio::test] async fn transition_applies_and_rejects_stale_revisions() { let temp = TempDir::new().expect("tempdir"); diff --git a/crates/andromeda-taskd/src/wire.rs b/crates/andromeda-taskd/src/wire.rs new file mode 100644 index 0000000..543fdc3 --- /dev/null +++ b/crates/andromeda-taskd/src/wire.rs @@ -0,0 +1,727 @@ +//! The HTTP wire contract. +//! +//! Handlers serialize the types in this module and **never** the runtime's +//! internal structs. Before this layer existed every handler called +//! `serde_json::to_value(record)` on [`TaskRecord`] / [`TaskEvent`] / +//! [`EvaluationReport`], which made the persisted, in-process representation +//! the public API by accident: renaming an internal field, adding one, or +//! reordering an enum variant silently changed what clients receive +//! (`docs/reviews/architecture-review.md` §3). +//! +//! The views below are deliberately *dumb*: they hold borrowed data, own no +//! logic, and exist to be the one place a wire-format change has to be typed +//! out. Because the mapping functions name every field explicitly, an internal +//! rename becomes a compile error here rather than a silent API break, and +//! `tests::task_json_shape_is_locked` pins the resulting document so an +//! *accidental* change fails a test instead of shipping. +//! +//! Scope note: the plan, capabilities, and outcomes are re-exposed as the +//! `andromeda-core` contract types they already are — those carry their own +//! versioning (`ActionPlan::schema_version`) and are the shared vocabulary of +//! the whole system, so mirroring them here would duplicate a contract rather +//! than insulate one. The golden test covers them anyway: it locks the whole +//! document, nested core fields included. + +use std::collections::BTreeMap; +use std::path::Path; + +use andromeda_core::{ + ActionId, ActionOutcome, ActionPlan, Capability, CapabilityId, IsolationLevel, OutcomeStatus, + TaskId, TaskState, +}; +use andromeda_policy::{DecisionEffect, PolicyDecision}; +use andromeda_runtime::{ + EvaluationReport, ListWarning, TaskEvent, TaskEventKind, TaskListing, TaskRecord, +}; +use chrono::{DateTime, Utc}; +use serde::Serialize; +use uuid::Uuid; + +/// Events a single-task read returns when the caller does not ask for more. +/// +/// `TaskRecord.events` is append-only and unbounded — every `evaluate`, +/// `transition`, `grant`, and `outcome` adds one, and an `Evaluated` event +/// embeds a decision per action — so a long-lived task's full history is +/// unbounded response size. Fifty is chosen to cover the whole history of an +/// ordinary task (create, grant, a handful of evaluations, the lifecycle +/// edges, one outcome per action) so the common case is not truncated at all, +/// while capping the pathological one. Callers that need more ask for it +/// explicitly and learn the total from `event_count`. +pub(crate) const DEFAULT_EVENT_LIMIT: usize = 50; + +/// Hard ceiling on `?events=`, whatever the caller asks for. +/// +/// A ceiling, not a suggestion: it is applied by clamping rather than by +/// rejecting, and `event_count` still reports the true total, so a caller can +/// always tell that more history exists. It bounds the response even when the +/// caller is the one being unreasonable. +pub(crate) const MAX_EVENT_LIMIT: usize = 1_000; + +/// One task as the API presents it. +/// +/// `events` holds only the most recent [`TaskView::bounded`] slice of the +/// history; `event_count` is always the true total, so a truncated read is +/// visible rather than silent. +#[derive(Debug, Serialize)] +pub(crate) struct TaskView<'a> { + plan: &'a ActionPlan, + state: TaskState, + revision: u64, + capabilities: &'a [Capability], + events: Vec>, + event_count: usize, + outcomes: &'a [ActionOutcome], +} + +impl<'a> TaskView<'a> { + /// Builds a view carrying at most `limit` of the *most recent* events, in + /// the same chronological order as the full history. + /// + /// `limit` is clamped to [`MAX_EVENT_LIMIT`] here, at the single point + /// where the wire response is built, so no caller path can widen it. + pub(crate) fn bounded(record: &'a TaskRecord, limit: usize) -> Self { + let limit = limit.min(MAX_EVENT_LIMIT); + let skipped = record.events.len().saturating_sub(limit); + Self { + plan: &record.plan, + state: record.state, + revision: record.revision, + capabilities: &record.capabilities, + events: record.events[skipped..] + .iter() + .map(EventView::from) + .collect(), + event_count: record.events.len(), + outcomes: &record.outcomes, + } + } +} + +impl<'a> From<&'a TaskRecord> for TaskView<'a> { + /// The default read: the most recent [`DEFAULT_EVENT_LIMIT`] events. + fn from(record: &'a TaskRecord) -> Self { + Self::bounded(record, DEFAULT_EVENT_LIMIT) + } +} + +/// One task as `GET /v1/tasks` presents it: enough to identify and triage a +/// task, with no event bodies at all. +/// +/// The listing used to return complete [`TaskRecord`]s, so every task's entire +/// event history — decision sets included — was multiplied by the number of +/// tasks in a single response. Counts answer the triage questions ("how much +/// history is there, is anything recorded yet?") in a fixed number of bytes; +/// a caller that wants the bodies reads the one task it cares about. +#[derive(Debug, Serialize)] +pub(crate) struct TaskSummaryView<'a> { + task_id: TaskId, + state: TaskState, + revision: u64, + /// The captured intent, so a human can tell tasks apart without a second + /// request per row. + intent_summary: &'a str, + requested_by: &'a str, + /// When the task was created, and when it last changed. Both come from the + /// event history (first and last entry), and are absent only for a record + /// with no events at all — which the service does not produce, since + /// `create` writes a `created` event. + created_at: Option>, + updated_at: Option>, + action_count: usize, + capability_count: usize, + event_count: usize, + outcome_count: usize, +} + +impl<'a> From<&'a TaskRecord> for TaskSummaryView<'a> { + fn from(record: &'a TaskRecord) -> Self { + Self { + task_id: record.plan.task_id, + state: record.state, + revision: record.revision, + intent_summary: &record.plan.intent.summary, + requested_by: &record.plan.intent.requested_by, + created_at: record.events.first().map(|event| event.occurred_at), + updated_at: record.events.last().map(|event| event.occurred_at), + action_count: record.plan.actions.len(), + capability_count: record.capabilities.len(), + event_count: record.events.len(), + outcome_count: record.outcomes.len(), + } + } +} + +/// One entry of the task event history. +#[derive(Debug, Serialize)] +pub(crate) struct EventView<'a> { + id: Uuid, + occurred_at: DateTime, + actor: &'a str, + kind: EventKindView<'a>, +} + +impl<'a> From<&'a TaskEvent> for EventView<'a> { + fn from(event: &'a TaskEvent) -> Self { + Self { + id: event.id, + occurred_at: event.occurred_at, + actor: &event.actor, + kind: EventKindView::from(&event.kind), + } + } +} + +/// The tagged payload of one event. +/// +/// Mirrors [`TaskEventKind`] on purpose rather than re-serializing it: this is +/// the enum a client matches on, so its variant names and payload fields are +/// the contract, and the exhaustive `match` below makes adding an internal +/// variant a compile error instead of a new, undocumented wire tag. +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(crate) enum EventKindView<'a> { + Created, + StateChanged { + from: TaskState, + to: TaskState, + external_side_effect_confirmed: Option, + }, + Granted { + capabilities: &'a [CapabilityId], + plan_fully_granted: bool, + }, + Evaluated { + effective_isolation: &'a BTreeMap, + external_side_effect_confirmed: bool, + decisions: BTreeMap>, + }, + OutcomeRecorded { + action_id: ActionId, + status: OutcomeStatus, + evidence_count: usize, + }, +} + +impl<'a> From<&'a TaskEventKind> for EventKindView<'a> { + fn from(kind: &'a TaskEventKind) -> Self { + match kind { + TaskEventKind::Created => Self::Created, + TaskEventKind::StateChanged { + from, + to, + external_side_effect_confirmed, + } => Self::StateChanged { + from: *from, + to: *to, + external_side_effect_confirmed: *external_side_effect_confirmed, + }, + TaskEventKind::Granted { + capabilities, + plan_fully_granted, + } => Self::Granted { + capabilities, + plan_fully_granted: *plan_fully_granted, + }, + TaskEventKind::Evaluated { + effective_isolation, + external_side_effect_confirmed, + decisions, + } => Self::Evaluated { + effective_isolation, + external_side_effect_confirmed: *external_side_effect_confirmed, + decisions: decision_views(decisions), + }, + TaskEventKind::OutcomeRecorded { + action_id, + status, + evidence_count, + } => Self::OutcomeRecorded { + action_id: *action_id, + status: *status, + evidence_count: *evidence_count, + }, + } + } +} + +/// One policy decision. +#[derive(Debug, Serialize)] +pub(crate) struct DecisionView<'a> { + effect: DecisionEffect, + reasons: &'a [String], +} + +impl<'a> From<&'a PolicyDecision> for DecisionView<'a> { + fn from(decision: &'a PolicyDecision) -> Self { + Self { + effect: decision.effect, + reasons: &decision.reasons, + } + } +} + +fn decision_views( + decisions: &BTreeMap, +) -> BTreeMap> { + decisions + .iter() + .map(|(action, decision)| (*action, DecisionView::from(decision))) + .collect() +} + +/// The result of one `POST /v1/tasks/{id}/evaluate`. +#[derive(Debug, Serialize)] +pub(crate) struct EvaluationReportView<'a> { + task_id: TaskId, + revision: u64, + decisions: BTreeMap>, +} + +impl<'a> From<&'a EvaluationReport> for EvaluationReportView<'a> { + fn from(report: &'a EvaluationReport) -> Self { + Self { + task_id: report.task_id, + revision: report.revision, + decisions: decision_views(&report.decisions), + } + } +} + +/// The body of `GET /v1/tasks`: summaries, never full records. +#[derive(Debug, Serialize)] +pub(crate) struct TaskListingView<'a> { + tasks: Vec>, + warnings: Vec>, +} + +impl<'a> From<&'a TaskListing> for TaskListingView<'a> { + fn from(listing: &'a TaskListing) -> Self { + Self { + tasks: listing.records.iter().map(TaskSummaryView::from).collect(), + warnings: listing.warnings.iter().map(ListWarningView::from).collect(), + } + } +} + +/// A record file that could not be read, reported instead of failing the whole +/// listing. +#[derive(Debug, Serialize)] +pub(crate) struct ListWarningView<'a> { + path: &'a Path, + reason: &'a str, +} + +impl<'a> From<&'a ListWarning> for ListWarningView<'a> { + fn from(warning: &'a ListWarning) -> Self { + Self { + path: &warning.path, + reason: &warning.reason, + } + } +} + +#[cfg(test)] +mod tests { + use andromeda_core::{ + ActionKind, ActionSpec, CapabilityResource, Evidence, FileAccess, Intent, + RecoverySemantics, RiskLevel, + }; + use serde_json::{Value, json}; + + use super::*; + + const TASK_UUID: &str = "11111111-1111-4111-8111-111111111111"; + const ACTION_UUID: &str = "22222222-2222-4222-8222-222222222222"; + const CAPABILITY_UUID: &str = "33333333-3333-4333-8333-333333333333"; + const EVENT_UUID: &str = "44444444-4444-4444-8444-444444444444"; + const TIMESTAMP: &str = "2026-01-02T03:04:05Z"; + + fn id(uuid: &str) -> T { + serde_json::from_value(json!(uuid)).expect("id") + } + + fn timestamp() -> DateTime { + TIMESTAMP.parse().expect("timestamp") + } + + /// A task exercising every wire construct at once: a plan, a capability, an + /// outcome with evidence, and one event of every kind. + fn representative_record() -> TaskRecord { + let task_id: TaskId = id(TASK_UUID); + let action_id: ActionId = id(ACTION_UUID); + let capability_id: CapabilityId = id(CAPABILITY_UUID); + let at = timestamp(); + + let mut intent = Intent::new("Inspect the workspace", "operator"); + intent.created_at = at; + + TaskRecord { + plan: ActionPlan { + schema_version: ActionPlan::CURRENT_SCHEMA_VERSION, + task_id, + intent, + actions: vec![ActionSpec { + id: action_id, + name: "Inspect directory".into(), + kind: ActionKind::Inspect, + target: "/workspace".into(), + arguments: BTreeMap::new(), + depends_on: Vec::new(), + required_capabilities: vec![capability_id], + risk: RiskLevel::L1Sandboxed, + recovery: RecoverySemantics::None, + }], + }, + state: TaskState::Verifying, + revision: 4, + capabilities: vec![Capability { + id: capability_id, + resource: CapabilityResource::Files { + root: "/workspace".into(), + access: FileAccess::Read, + }, + issued_to: task_id.to_string(), + issued_at: at, + expires_at: None, + single_use: false, + signature: None, + }], + events: representative_events(action_id, capability_id), + outcomes: vec![ActionOutcome { + action_id, + status: OutcomeStatus::Succeeded, + started_at: at, + finished_at: at, + evidence: vec![Evidence { + kind: "assertion".into(), + summary: "listing matched".into(), + attributes: BTreeMap::new(), + }], + error: None, + }], + } + } + + /// One event of every kind, so no variant of the wire enum is unlocked. + fn representative_events(action_id: ActionId, capability_id: CapabilityId) -> Vec { + let event_id: Uuid = id(EVENT_UUID); + let at = timestamp(); + let event = |actor: &str, kind| TaskEvent { + id: event_id, + occurred_at: at, + actor: actor.to_owned(), + kind, + }; + vec![ + event("operator", TaskEventKind::Created), + event( + "approver", + TaskEventKind::Granted { + capabilities: vec![capability_id], + plan_fully_granted: true, + }, + ), + event( + "policy-engine", + TaskEventKind::Evaluated { + effective_isolation: [(action_id, IsolationLevel::Sandbox)] + .into_iter() + .collect(), + external_side_effect_confirmed: false, + decisions: [( + action_id, + PolicyDecision { + effect: DecisionEffect::Allow, + reasons: vec!["all checks passed".into()], + }, + )] + .into_iter() + .collect(), + }, + ), + event( + "runner", + TaskEventKind::StateChanged { + from: TaskState::Ready, + to: TaskState::Running, + external_side_effect_confirmed: Some(true), + }, + ), + event( + "executor", + TaskEventKind::OutcomeRecorded { + action_id, + status: OutcomeStatus::Succeeded, + evidence_count: 1, + }, + ), + ] + } + + /// The serialized form of [`representative_events`]: the tag and payload + /// of every event kind a client can receive. + fn golden_events_json() -> Value { + json!([ + { + "id": EVENT_UUID, + "occurred_at": TIMESTAMP, + "actor": "operator", + "kind": {"type": "created"}, + }, + { + "id": EVENT_UUID, + "occurred_at": TIMESTAMP, + "actor": "approver", + "kind": { + "type": "granted", + "capabilities": [CAPABILITY_UUID], + "plan_fully_granted": true, + }, + }, + { + "id": EVENT_UUID, + "occurred_at": TIMESTAMP, + "actor": "policy-engine", + "kind": { + "type": "evaluated", + "effective_isolation": {ACTION_UUID: "sandbox"}, + "external_side_effect_confirmed": false, + "decisions": { + ACTION_UUID: { + "effect": "allow", + "reasons": ["all checks passed"], + }, + }, + }, + }, + { + "id": EVENT_UUID, + "occurred_at": TIMESTAMP, + "actor": "runner", + "kind": { + "type": "state_changed", + "from": "ready", + "to": "running", + "external_side_effect_confirmed": true, + }, + }, + { + "id": EVENT_UUID, + "occurred_at": TIMESTAMP, + "actor": "executor", + "kind": { + "type": "outcome_recorded", + "action_id": ACTION_UUID, + "status": "succeeded", + "evidence_count": 1, + }, + }, + ]) + } + + /// The document `GET /v1/tasks/{id}` is expected to produce for + /// [`representative_record`], written out in full. + fn golden_task_json() -> Value { + json!({ + "plan": { + "schema_version": 1, + "task_id": TASK_UUID, + "intent": { + "summary": "Inspect the workspace", + "requested_by": "operator", + "created_at": TIMESTAMP, + }, + "actions": [{ + "id": ACTION_UUID, + "name": "Inspect directory", + "kind": "inspect", + "target": "/workspace", + "arguments": {}, + "depends_on": [], + "required_capabilities": [CAPABILITY_UUID], + "risk": "l1_sandboxed", + "recovery": "none", + }], + }, + "state": "verifying", + "revision": 4, + "capabilities": [{ + "id": CAPABILITY_UUID, + "resource": { + "type": "files", + "root": "/workspace", + "access": "read", + }, + "issued_to": TASK_UUID, + "issued_at": TIMESTAMP, + "expires_at": Value::Null, + "single_use": false, + }], + "events": golden_events_json(), + "event_count": 5, + "outcomes": [{ + "action_id": ACTION_UUID, + "status": "succeeded", + "started_at": TIMESTAMP, + "finished_at": TIMESTAMP, + "evidence": [{ + "kind": "assertion", + "summary": "listing matched", + "attributes": {}, + }], + "error": Value::Null, + }], + }) + } + + /// The wire format, written out by hand. Any change to a serialized field + /// name, enum tag, or nesting — whether made here or by renaming an + /// internal field in `andromeda-runtime`/`andromeda-core` — fails this + /// test, which is the whole point: the API cannot drift silently. + /// + /// Updating this literal is a deliberate act, and one that must come with + /// a documentation change in `docs/development/task-control-plane.md`. + #[test] + fn task_json_shape_is_locked() { + let record = representative_record(); + let encoded = serde_json::to_value(TaskView::from(&record)).expect("serialize view"); + assert_eq!(encoded, golden_task_json()); + } + + /// The single-task view differs from the internal record in exactly one + /// documented way: the bounded `events` slice and the `event_count` that + /// makes the bound visible. Everything else is still identical, so an + /// internal field added or renamed in `andromeda-runtime` cannot slip onto + /// the wire unnoticed under cover of "the DTO layer changed something". + #[test] + fn the_view_differs_from_the_record_only_by_the_event_bound() { + let record = representative_record(); + let mut view = serde_json::to_value(TaskView::from(&record)).expect("view"); + let internal = serde_json::to_value(&record).expect("record"); + assert_eq!( + view.as_object_mut() + .expect("object") + .remove("event_count") + .expect("event_count"), + json!(record.events.len()), + ); + assert_eq!(view, internal); + } + + /// Truncation keeps the *most recent* events, in order, and reports the + /// true total rather than the truncated length. + #[test] + fn a_bounded_read_keeps_the_newest_events() { + let record = representative_record(); + let view = serde_json::to_value(TaskView::bounded(&record, 2)).expect("view"); + let events = view["events"].as_array().expect("events"); + assert_eq!(events.len(), 2); + assert_eq!(events[0]["actor"], "runner"); + assert_eq!(events[1]["actor"], "executor"); + assert_eq!(view["event_count"], json!(5)); + + // A caller cannot widen the window past the ceiling. + let view = serde_json::to_value(TaskView::bounded(&record, usize::MAX)).expect("view"); + assert_eq!(view["events"].as_array().expect("events").len(), 5); + } + + /// The listing projection, written out in full: no event bodies, no plan, + /// no capabilities — only what triage needs. + #[test] + fn task_summary_json_shape_is_locked() { + let record = representative_record(); + let encoded = + serde_json::to_value(TaskSummaryView::from(&record)).expect("serialize summary"); + assert_eq!( + encoded, + json!({ + "task_id": TASK_UUID, + "state": "verifying", + "revision": 4, + "intent_summary": "Inspect the workspace", + "requested_by": "operator", + "created_at": TIMESTAMP, + "updated_at": TIMESTAMP, + "action_count": 1, + "capability_count": 1, + "event_count": 5, + "outcome_count": 1, + }) + ); + } + + /// Measures what the projection is for. `GET /v1/tasks` used to serialize + /// whole `TaskRecord`s, so a task's entire history rode along in the + /// listing — once per task. The summary is constant-size in the number of + /// events, so the listing no longer grows as tasks are evaluated. + #[test] + fn the_listing_projection_is_constant_in_the_event_count() { + let mut sizes = Vec::new(); + for events in [1_usize, 1_000] { + let mut record = representative_record(); + let action_id: ActionId = id(ACTION_UUID); + let capability_id: CapabilityId = id(CAPABILITY_UUID); + record.events = std::iter::repeat_with(|| { + representative_events(action_id, capability_id).into_iter() + }) + .flatten() + .take(events) + .collect(); + + // What the endpoint returned before this change: the whole record. + let before = serde_json::to_vec(&record).expect("record").len(); + let after = serde_json::to_vec(&TaskSummaryView::from(&record)) + .expect("summary") + .len(); + eprintln!("events={events:>5} full record={before:>8} B summary={after:>4} B"); + sizes.push((events, before, after)); + } + + let (_, small_before, small_after) = sizes[0]; + let (_, large_before, large_after) = sizes[1]; + // The summary does not grow with history (only `event_count`'s digits + // change), while the full record grows without bound. + assert!( + large_after < small_after + 8, + "summary grew with the event history: {small_after} -> {large_after}" + ); + assert!(large_before > small_before * 100); + assert!( + large_before > large_after * 100, + "expected a >100x reduction, got {large_before} -> {large_after}" + ); + } + + /// Enum variants reach the wire as names, so the names are contract too. + /// A rename in `andromeda-core` would otherwise change the API without + /// touching a single line in this crate. Every variant must be listed. + #[test] + fn state_and_effect_names_are_pinned() { + for (state, name) in [ + (TaskState::AwaitingApproval, "awaiting_approval"), + (TaskState::Ready, "ready"), + (TaskState::Running, "running"), + (TaskState::Verifying, "verifying"), + (TaskState::Succeeded, "succeeded"), + (TaskState::Failed, "failed"), + (TaskState::Cancelling, "cancelling"), + (TaskState::Cancelled, "cancelled"), + (TaskState::Compensating, "compensating"), + (TaskState::Compensated, "compensated"), + ] { + assert_eq!(serde_json::to_value(state).expect("state"), json!(name)); + } + for (effect, name) in [ + (DecisionEffect::Allow, "allow"), + (DecisionEffect::Ask, "ask"), + (DecisionEffect::Deny, "deny"), + ] { + assert_eq!(serde_json::to_value(effect).expect("effect"), json!(name)); + } + + // `draft` was a state nothing produced and no edge reached. It is gone + // from the vocabulary, in both directions: a client will never be sent + // it, and `{"to": "draft"}` no longer parses at all. + assert!(serde_json::from_value::(json!("draft")).is_err()); + } +} diff --git a/docs/development/task-control-plane.md b/docs/development/task-control-plane.md index 5f2381b..7991263 100644 --- a/docs/development/task-control-plane.md +++ b/docs/development/task-control-plane.md @@ -36,8 +36,8 @@ ActionKind 决定不可降低的风险下限。模型可以把动作声明得更 |---|---|---| | GET | `/healthz` | 服务状态、API 版本,以及当前安全姿态:`authentication`(恒为 `bearer_token`)与 `capability_admission`(`unsigned_allowed` / `require_signed`) | | POST | `/v1/tasks` | 校验并创建任务(重复 task_id 返回 409 `already_exists`) | -| GET | `/v1/tasks` | 列出任务,响应为 `{"tasks": [...], "warnings": [...]}`;损坏的记录文件被跳过并记入 `warnings`,不会让整个列表失败 | -| GET | `/v1/tasks/{id}` | 读取任务 | +| GET | `/v1/tasks` | 列出任务**摘要**,响应为 `{"tasks": [...], "warnings": [...]}`;**不返回事件史**(见下方"读取形状与事件上界");损坏的记录文件被跳过并记入 `warnings`,不会让整个列表失败 | +| GET | `/v1/tasks/{id}` | 读取单个任务;默认只返回**最近 50 条**事件与总数 `event_count`,可用 `?events=` 索取更多(硬上限 1000) | | POST | `/v1/tasks/{id}/capabilities` | 给已存在的任务补授权:追加 capability,记 `granted` 事件并使 revision +1。每个新 capability 必须 `issued_to == plan.task_id` 且当前有效(未过期、已到 `issued_at`),否则返回 422;带 `expected_revision` 做乐观并发 | | POST | `/v1/tasks/{id}/outcomes` | 记录单个 action 的执行结果与证据;追加 `outcome_recorded` 事件并使 revision +1。只允许在 `Running`/`Verifying` 状态记录,每个 action 至多一条(重复返回 422),action 必须属于该计划 | | POST | `/v1/tasks/{id}/evaluate` | 评估、不执行;**逐 action** 解析隔离等级,结果作为 `evaluated` 事件追加到任务事件史并使 revision +1 | @@ -45,6 +45,23 @@ ActionKind 决定不可降低的风险下限。模型可以把动作声明得更 所有 `TaskService` 调用在 `tokio::task::spawn_blocking` 中执行,阻塞的文件锁和 fsync 不会占用 async worker,`/healthz` 在锁竞争时依旧可响应。 +### wire 契约与内部类型分离 + +handler **只**序列化 `crates/andromeda-taskd/src/wire.rs` 里的 DTO,绝不直接序列化 `TaskRecord`/`TaskEvent`/`EvaluationReport`。此前 handler 直接 `serde_json::to_value(record)`,等于把"持久化/进程内表示"当成了对外 API:重命名一个内部字段就是一次静默的破坏性 API 变更(架构评审 #3)。 + +DTO 层是**唯一**需要落笔改动 wire 格式的地方:映射函数逐字段显式书写,内部重命名会变成该文件里的编译错误;`wire::tests::task_json_shape_is_locked` 用手写的完整文档锁定序列化结果,`task_summary_json_shape_is_locked` 锁定列表投影。改变 wire 格式必须同时改这两个字面量与本文档。 + +plan、capability、outcome 仍以 `andromeda-core` 契约类型原样出现:它们本就是全系统共享的词汇且自带版本(`ActionPlan.schema_version`),再镜像一层只会复制契约而不是隔离契约——golden 测试覆盖的是**整篇文档**,嵌套的 core 字段同样被锁定。 + +### 读取形状与事件上界 + +`TaskRecord.events` 只增不减(每次 `evaluate`/`transition`/`grant`/`outcome` 各追加一条,`evaluated` 事件还内嵌逐 action 的完整决策集),所以"读取时返回全部事件"等于响应大小无界: + +- `GET /v1/tasks/{id}` 默认只返回**最近 `50`** 条事件(`wire::DEFAULT_EVENT_LIMIT`),并附带 `event_count`(真实总数,不是本次返回的条数),因此截断是**可见**的,不是静默的。需要更多时用 `?events=`:`n` 被钳到硬上限 `1000`(`wire::MAX_EVENT_LIMIT`),钳制不会报错但 `event_count` 仍是真实总数,调用方总能判断还有多少历史。`?events=0` 合法,表示"只要记录不要历史"。参数名拼错(例如 `?event=5`)返回 400 `bad_request`,不会被丢弃后按默认值应答。 +- `GET /v1/tasks` **不返回**任何事件体,只返回摘要投影:`task_id`、`state`、`revision`、`intent_summary`、`requested_by`、`created_at`/`updated_at`(取自事件史首尾)、`action_count`、`capability_count`、`event_count`、`outcome_count`。列表响应因此与事件数量无关:实测同一个任务在 1000 条事件时,整条记录 224 390 B,摘要 297 B。要事件体就去读**具体那一个**任务。 + +其余返回完整任务的端点(create/grant/outcome/transition)同样走默认上界与 `event_count`。 + ### 本地鉴权(强制,不可关闭) **每个请求都必须携带本地 bearer 令牌,`/healthz` 也不例外**: @@ -88,6 +105,14 @@ Authorization: Bearer <令牌> 生产部署另有内核级纵深防御:`andromeda-taskd.service` 设置 `IPAddressAllow=localhost` / `IPAddressDeny=any`。 +### 状态机的入口只有两个 + +`create` 对整盘计划求值后二选一:完全授权则 `Ready`,否则 `AwaitingApproval`。除此之外**没有**第三个入口——任务状态机不存在 `Draft`。`Draft` 曾经存在过:没有任何代码路径产生它,也没有任何边指向它,`{"to": "draft"}` 必然被拒。一个安全相关状态机里"永远不可能处于"的状态是纯粹的契约噪声——每个读 `state` 的客户端仍要为它写分支——所以它连同三条出边一起被删除。 + +状态机的**完整**边集由 `crates/andromeda-core/src/task.rs` 的 `the_transition_matrix_is_pinned` 逐对锁定(对所有有序状态对断言允许/拒绝),`the_state_list_covers_the_whole_machine` 再保证新增状态无法漏出该矩阵。 + +升级影响:磁盘上不可能存在 `"state": "draft"` 的记录(没有任何代码写得出来),因此删除该变体不会作废已持久化的任务;手工构造的此类文件在 `list` 中会作为损坏记录进入 `warnings`。 + ### `Ready` 状态的语义 创建时,任务进入 `Ready` 当且仅当计划中每个 action 在"最宽松执行假设"(隔离恰好等于该 action 风险等级要求的最低隔离、外部副作用视为已确认)下会被确定性策略引擎判为 Allow。这保证了: