feat(orchestration): implement event sourcing core #23

Closed
claude-desktop wants to merge 2 commits from grunt/4 into main
Collaborator

Summary

  • Implement OrchestrationCommand enum (15 command variants covering projects, threads, turns, provider events, checkpoints, and git operations)
  • Implement OrchestrationEvent enum (17 event variants) and StoredEvent envelope
  • Implement pure decide() function that validates commands against the read model and produces domain events, with DeciderError for invariant violations
  • Implement pure project() fold function that applies events to the OrchestrationReadModel, including cascade deletions for projects/threads
  • Define ProviderEvent enum minimally in the provider module (required by IngestProviderEvent command)
  • 34 unit tests covering all command variants, projector state transitions, supervised vs full-access mode, cascade deletions, serde round-trips, and error messages

Test plan

  • cargo fmt --check passes
  • cargo clippy -p forge-agent-backend -- -D warnings passes
  • cargo test -p forge-agent-backend — 34 tests pass
  • No dead code, no commented-out blocks

Closes #4

## Summary - Implement `OrchestrationCommand` enum (15 command variants covering projects, threads, turns, provider events, checkpoints, and git operations) - Implement `OrchestrationEvent` enum (17 event variants) and `StoredEvent` envelope - Implement pure `decide()` function that validates commands against the read model and produces domain events, with `DeciderError` for invariant violations - Implement pure `project()` fold function that applies events to the `OrchestrationReadModel`, including cascade deletions for projects/threads - Define `ProviderEvent` enum minimally in the provider module (required by `IngestProviderEvent` command) - 34 unit tests covering all command variants, projector state transitions, supervised vs full-access mode, cascade deletions, serde round-trips, and error messages ## Test plan - [x] `cargo fmt --check` passes - [x] `cargo clippy -p forge-agent-backend -- -D warnings` passes - [x] `cargo test -p forge-agent-backend` — 34 tests pass - [x] No dead code, no commented-out blocks Closes #4
feat(orchestration): implement event sourcing core
All checks were successful
qa / qa (pull_request) Successful in 10m35s
36314980f3
Add commands, events, decider, and projector for the orchestration
module. The decider is a pure function that validates commands against
the read model and produces domain events. The projector folds events
into the read model. ProviderEvent is defined minimally in the provider
module since commands depend on it.

Includes 34 tests covering all command variants, projector state
transitions, cascade deletions, serde round-trips, and error cases.

Closes #4

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Ghost left a comment

Overall the structure is solid — pure functions, clean separation of decider/projector, good test coverage for the happy paths. Three issues need fixing before merge: one functional bug (data loss) and two missing negative tests that the AC explicitly requires.

Overall the structure is solid — pure functions, clean separation of decider/projector, good test coverage for the happy paths. Three issues need fixing before merge: one functional bug (data loss) and two missing negative tests that the AC explicitly requires.
@ -0,0 +395,4 @@
])
}
ProviderEvent::Error { code, message } => {

Bug: checkpoint description is permanently lost.

The command carries a description field but the decider silently drops it with description: _, and CheckpointCreated has no description field. The projector then hardcodes description: String::new(). Any description the user provides is thrown away at the boundary and can never be recovered from the event log.

Fix: add description: String to CheckpointCreated in events.rs, pass description.clone() from the command here, and use description.clone() instead of String::new() in the projector's Checkpoint { .. } construction.

**Bug: checkpoint description is permanently lost.** The command carries a `description` field but the decider silently drops it with `description: _`, and `CheckpointCreated` has no `description` field. The projector then hardcodes `description: String::new()`. Any description the user provides is thrown away at the boundary and can never be recovered from the event log. Fix: add `description: String` to `CheckpointCreated` in `events.rs`, pass `description.clone()` from the command here, and use `description.clone()` instead of `String::new()` in the projector's `Checkpoint { .. }` construction.
@ -0,0 +300,4 @@
#[test]
fn turn_sequence_increments() {
let (model, _, thread_id) = setup_thread();

Missing negative test — AC violation.

The decider has a guard at decider.rs that rejects DeleteProject when the project has running threads (InvariantViolation("cannot delete project with running threads")), but there is no test exercising this path. The issue AC says "at least one positive + one negative test per command variant"DeleteProject has a not-found negative but not a running-threads negative.

Add a test: create a project, create a thread, start a turn (puts thread in Running), then assert that DeleteProject returns DeciderError::InvariantViolation.

**Missing negative test — AC violation.** The decider has a guard at `decider.rs` that rejects `DeleteProject` when the project has running threads (`InvariantViolation("cannot delete project with running threads")`), but there is no test exercising this path. The issue AC says _"at least one positive + one negative test per command variant"_ — `DeleteProject` has a not-found negative but not a running-threads negative. Add a test: create a project, create a thread, start a turn (puts thread in `Running`), then assert that `DeleteProject` returns `DeciderError::InvariantViolation`.
@ -0,0 +598,4 @@
let cmd = OrchestrationCommand::CommitChanges {
thread_id,
message: "feat: add feature".into(),
};

Missing negative tests for ApprovePendingAction and RejectPendingAction — AC violation.

Both commands validate that the thread is in AwaitingApproval status and return InvariantViolation otherwise, but only positive tests exist. The issue AC requires at least one negative per command variant.

Add two tests:

  1. approve_pending_action_on_idle_thread_fails — call ApprovePendingAction on a thread in Idle status, assert DeciderError::InvariantViolation.
  2. reject_pending_action_on_idle_thread_fails — same for RejectPendingAction.
**Missing negative tests for `ApprovePendingAction` and `RejectPendingAction` — AC violation.** Both commands validate that the thread is in `AwaitingApproval` status and return `InvariantViolation` otherwise, but only positive tests exist. The issue AC requires at least one negative per command variant. Add two tests: 1. `approve_pending_action_on_idle_thread_fails` — call `ApprovePendingAction` on a thread in `Idle` status, assert `DeciderError::InvariantViolation`. 2. `reject_pending_action_on_idle_thread_fails` — same for `RejectPendingAction`.
Ghost left a comment

Overall the structure is solid — pure functions, clean separation of decider/projector, good test coverage for the happy paths. Three issues need fixing before merge: one functional bug (data loss) and two missing negative tests that the AC explicitly requires.

Overall the structure is solid — pure functions, clean separation of decider/projector, good test coverage for the happy paths. Three issues need fixing before merge: one functional bug (data loss) and two missing negative tests that the AC explicitly requires.
@ -0,0 +395,4 @@
])
}
ProviderEvent::Error { code, message } => {

Bug: checkpoint description is permanently lost.

The command carries a description field but the decider silently drops it with description: _, and CheckpointCreated has no description field. The projector then hardcodes description: String::new(). Any description the user provides is thrown away at the boundary and can never be recovered from the event log.

Fix: add description: String to CheckpointCreated in events.rs, pass description.clone() from the command here, and use description.clone() instead of String::new() in the projector's Checkpoint { .. } construction.

**Bug: checkpoint description is permanently lost.** The command carries a `description` field but the decider silently drops it with `description: _`, and `CheckpointCreated` has no `description` field. The projector then hardcodes `description: String::new()`. Any description the user provides is thrown away at the boundary and can never be recovered from the event log. Fix: add `description: String` to `CheckpointCreated` in `events.rs`, pass `description.clone()` from the command here, and use `description.clone()` instead of `String::new()` in the projector's `Checkpoint { .. }` construction.
@ -0,0 +300,4 @@
#[test]
fn turn_sequence_increments() {
let (model, _, thread_id) = setup_thread();

Missing negative test — AC violation.

The decider has a guard at decider.rs that rejects DeleteProject when the project has running threads (InvariantViolation("cannot delete project with running threads")), but there is no test exercising this path. The issue AC says "at least one positive + one negative test per command variant"DeleteProject has a not-found negative but not a running-threads negative.

Add a test: create a project, create a thread, start a turn (puts thread in Running), then assert that DeleteProject returns DeciderError::InvariantViolation.

**Missing negative test — AC violation.** The decider has a guard at `decider.rs` that rejects `DeleteProject` when the project has running threads (`InvariantViolation("cannot delete project with running threads")`), but there is no test exercising this path. The issue AC says _"at least one positive + one negative test per command variant"_ — `DeleteProject` has a not-found negative but not a running-threads negative. Add a test: create a project, create a thread, start a turn (puts thread in `Running`), then assert that `DeleteProject` returns `DeciderError::InvariantViolation`.
@ -0,0 +598,4 @@
let cmd = OrchestrationCommand::CommitChanges {
thread_id,
message: "feat: add feature".into(),
};

Missing negative tests for ApprovePendingAction and RejectPendingAction — AC violation.

Both commands validate that the thread is in AwaitingApproval status and return InvariantViolation otherwise, but only positive tests exist. The issue AC requires at least one negative per command variant.

Add two tests:

  1. approve_pending_action_on_idle_thread_fails — call ApprovePendingAction on a thread in Idle status, assert DeciderError::InvariantViolation.
  2. reject_pending_action_on_idle_thread_fails — same for RejectPendingAction.
**Missing negative tests for `ApprovePendingAction` and `RejectPendingAction` — AC violation.** Both commands validate that the thread is in `AwaitingApproval` status and return `InvariantViolation` otherwise, but only positive tests exist. The issue AC requires at least one negative per command variant. Add two tests: 1. `approve_pending_action_on_idle_thread_fails` — call `ApprovePendingAction` on a thread in `Idle` status, assert `DeciderError::InvariantViolation`. 2. `reject_pending_action_on_idle_thread_fails` — same for `RejectPendingAction`.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
claude-desktop closed this pull request 2026-04-16 21:59:20 +00:00
Some checks failed
qa / qa (pull_request) Has been cancelled
Required
Details

Pull request closed

Sign in to join this conversation.
No description provided.