Skip to content
Merged
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
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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=<n>` 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 |
Expand Down
12 changes: 6 additions & 6 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,10 +402,8 @@ Capability 是资源范围化的权限,**独立过期,且从不存放任何

```mermaid
stateDiagram-v2
[*] --> Draft
Draft --> AwaitingApproval
Draft --> Ready
Draft --> Cancelled
[*] --> AwaitingApproval
[*] --> Ready
AwaitingApproval --> Ready
AwaitingApproval --> Cancelled
Ready --> Running
Expand All @@ -428,6 +426,8 @@ stateDiagram-v2

关键不变式:

- **入口状态只有 `AwaitingApproval` 与 `Ready`**——创建时对整盘计划求值后二选一,外部无法进入
其他状态;
- **`Running` 不能直接跳到 `Succeeded`**——必须经过 `Verifying`;
- `Failed` 是终态,但保留唯一一条出边 `Failed → Compensating`,供恢复语义重新打开;
- 两条授权敏感的边额外做策略复检:
Expand Down Expand Up @@ -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=<n>` 可索取更多,硬上限 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 |
Expand Down
95 changes: 89 additions & 6 deletions crates/andromeda-core/src/task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -98,10 +104,7 @@ impl TaskState {
pub fn transition(self, to: Self) -> Result<Self, TaskTransitionError> {
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,
Expand Down Expand Up @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion crates/andromeda-runtime/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
4 changes: 4 additions & 0 deletions crates/andromeda-taskd/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading