| 1 | //! Changes, observations, and receipts — the reducer's entire input/output |
| 2 | //! vocabulary. |
| 3 | //! |
| 4 | //! The reducer never reads clocks or RNG: [`ChangeCtx`] carries the timestamp, |
| 5 | //! the session identity used for deterministic ID derivation, and an optional |
| 6 | //! idempotency key. Same snapshot + same change + same ctx ⇒ same result, |
| 7 | //! always. |
| 8 | //! |
| 9 | //! Spec-silent shapes chosen minimally here (documented on each type): |
| 10 | //! [`WorkNodePatch`], [`WorkGraphProposal`], [`ApprovalRef`], and the |
| 11 | //! placeholder observation types that later slices' liveness adapters will |
| 12 | //! feed ([`OperationObservation`], [`OwnerState`], [`CancelOutcome`]). |
| 13 | |
| 14 | use serde::{Deserialize, Serialize}; |
| 15 | |
| 16 | use super::ids::{ChangeId, ProposalId, WorkEdgeId, WorkNodeId}; |
| 17 | use super::model::{ |
| 18 | AcceptanceRequirement, CompatProjectionState, EvidenceRef, IdempotencyKey, NodeState, |
| 19 | OperationBinding, Provenance, Ts, WorkActivityEvent, WorkEdge, WorkNode, |
| 20 | }; |
| 21 | |
| 22 | /// A single mutation of the work graph. The reducer is the only write path; |
| 23 | /// UI, tools, and runtime adapters all speak this vocabulary. |
| 24 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 25 | #[serde(rename_all = "snake_case")] |
| 26 | // Variants intentionally carry full payloads (a node, a proposal) rather than |
| 27 | // boxed indirection: changes are transient values, not stored long-term. |
| 28 | #[allow(clippy::large_enum_variant)] |
| 29 | pub enum WorkGraphChange { |
| 30 | AddNode { |
| 31 | node: WorkNode, |
| 32 | }, |
| 33 | UpdateNode { |
| 34 | id: WorkNodeId, |
| 35 | patch: WorkNodePatch, |
| 36 | }, |
| 37 | AddEdge { |
| 38 | edge: WorkEdge, |
| 39 | }, |
| 40 | RemoveEdge { |
| 41 | id: WorkEdgeId, |
| 42 | }, |
| 43 | BindOperation { |
| 44 | node: WorkNodeId, |
| 45 | binding: OperationBinding, |
| 46 | }, |
| 47 | ReconcileOperation { |
| 48 | node: WorkNodeId, |
| 49 | obs: OperationObservation, |
| 50 | }, |
| 51 | AttachEvidence { |
| 52 | node: WorkNodeId, |
| 53 | evidence: EvidenceRef, |
| 54 | }, |
| 55 | ProposePlanDiff { |
| 56 | proposal: WorkGraphProposal, |
| 57 | }, |
| 58 | /// Explicitly retire a pending proposal before a replacement is |
| 59 | /// proposed. This keeps repeated "Revise plan" turns reviewable without |
| 60 | /// silently rewriting or accumulating stale proposals. |
| 61 | WithdrawPlanDiff { |
| 62 | proposal_id: ProposalId, |
| 63 | }, |
| 64 | AcceptPlanDiff { |
| 65 | proposal_id: ProposalId, |
| 66 | approval: ApprovalRef, |
| 67 | }, |
| 68 | Supersede { |
| 69 | old: WorkNodeId, |
| 70 | replacement: WorkNodeId, |
| 71 | }, |
| 72 | /// Atomically replace the inputs for the legacy Plan/To-do projections. |
| 73 | ReplaceCompatProjection { |
| 74 | compat: CompatProjectionState, |
| 75 | }, |
| 76 | /// Record the canonical digest of a completed legacy import. |
| 77 | SetImportDigest { |
| 78 | digest: String, |
| 79 | }, |
| 80 | /// Append one bounded, configuration-only activity receipt. |
| 81 | RecordActivity { |
| 82 | event: WorkActivityEvent, |
| 83 | }, |
| 84 | } |
| 85 | |
| 86 | impl WorkGraphChange { |
| 87 | /// Stable discriminant name recorded on receipts. Names only — receipts |
| 88 | /// never carry payload text. |
| 89 | #[must_use] |
| 90 | pub fn kind_name(&self) -> &'static str { |
| 91 | match self { |
| 92 | WorkGraphChange::AddNode { .. } => "add_node", |
| 93 | WorkGraphChange::UpdateNode { .. } => "update_node", |
| 94 | WorkGraphChange::AddEdge { .. } => "add_edge", |
| 95 | WorkGraphChange::RemoveEdge { .. } => "remove_edge", |
| 96 | WorkGraphChange::BindOperation { .. } => "bind_operation", |
| 97 | WorkGraphChange::ReconcileOperation { .. } => "reconcile_operation", |
| 98 | WorkGraphChange::AttachEvidence { .. } => "attach_evidence", |
| 99 | WorkGraphChange::ProposePlanDiff { .. } => "propose_plan_diff", |
| 100 | WorkGraphChange::WithdrawPlanDiff { .. } => "withdraw_plan_diff", |
| 101 | WorkGraphChange::AcceptPlanDiff { .. } => "accept_plan_diff", |
| 102 | WorkGraphChange::Supersede { .. } => "supersede", |
| 103 | WorkGraphChange::ReplaceCompatProjection { .. } => "replace_compat_projection", |
| 104 | WorkGraphChange::SetImportDigest { .. } => "set_import_digest", |
| 105 | WorkGraphChange::RecordActivity { .. } => "record_activity", |
| 106 | } |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | /// Partial update of a node. Spec-silent shape: `Option` per patchable field, |
| 111 | /// `None` meaning "leave unchanged". Identity, kind, binding, and evidence |
| 112 | /// are deliberately NOT patchable here — they move only through their |
| 113 | /// dedicated changes (`BindOperation`, `AttachEvidence`, `Supersede`). |
| 114 | #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] |
| 115 | pub struct WorkNodePatch { |
| 116 | pub title: Option<String>, |
| 117 | pub state: Option<NodeState>, |
| 118 | pub acceptance: Option<Vec<AcceptanceRequirement>>, |
| 119 | pub provenance: Option<Provenance>, |
| 120 | } |
| 121 | |
| 122 | /// A reviewable plan diff. Spec-silent shape: explicit added/updated/removed |
| 123 | /// sets rather than nested changes, so the whole delta is inspectable before |
| 124 | /// acceptance and applies atomically (validated as one unit — no silent |
| 125 | /// mutation of objectives, dependencies, acceptance, or scope). |
| 126 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 127 | pub struct WorkGraphProposal { |
| 128 | pub id: ProposalId, |
| 129 | #[serde(default)] |
| 130 | pub added_nodes: Vec<WorkNode>, |
| 131 | #[serde(default)] |
| 132 | pub added_edges: Vec<WorkEdge>, |
| 133 | #[serde(default)] |
| 134 | pub updated_nodes: Vec<ProposedNodeUpdate>, |
| 135 | #[serde(default)] |
| 136 | pub removed_nodes: Vec<WorkNodeId>, |
| 137 | #[serde(default)] |
| 138 | pub removed_edges: Vec<WorkEdgeId>, |
| 139 | /// Graph-owned inputs for the legacy Plan/To-do projections. This is part |
| 140 | /// of the reviewed scope delta and is applied atomically with the graph |
| 141 | /// changes. Older saved proposals deserialize with no replacement. |
| 142 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 143 | pub replacement_compat: Option<CompatProjectionState>, |
| 144 | } |
| 145 | |
| 146 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 147 | pub struct ProposedNodeUpdate { |
| 148 | pub id: WorkNodeId, |
| 149 | pub patch: WorkNodePatch, |
| 150 | } |
| 151 | |
| 152 | /// Reference to the approval that accepted a plan diff. Spec-silent shape: |
| 153 | /// a reference-only string (approval receipt / user action handle), recorded |
| 154 | /// on the Approval node the acceptance creates. |
| 155 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 156 | pub struct ApprovalRef { |
| 157 | pub reference: String, |
| 158 | } |
| 159 | |
| 160 | /// Lifecycle state as reported by an operation's owner. Placeholder for the |
| 161 | /// liveness slice; present now so reducer signatures are final. |
| 162 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 163 | #[serde(rename_all = "snake_case")] |
| 164 | pub enum OwnerState { |
| 165 | Initializing, |
| 166 | Running, |
| 167 | Waiting, |
| 168 | Completed, |
| 169 | Failed, |
| 170 | Cancelled, |
| 171 | } |
| 172 | |
| 173 | /// Typed cancellation outcomes, mirroring real owner semantics (immediate |
| 174 | /// abort vs teardown-wait vs already-finished vs unknown-after-restart). |
| 175 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 176 | #[serde(rename_all = "snake_case")] |
| 177 | pub enum CancelOutcome { |
| 178 | Requested, |
| 179 | Acknowledged, |
| 180 | Forced, |
| 181 | AlreadyFinished, |
| 182 | NotFound, |
| 183 | StaleUnknown, |
| 184 | } |
| 185 | |
| 186 | /// An observation about a bound operation, produced by owner adapters (later |
| 187 | /// slice) and consumed by the reducer. The reducer applies these purely; it |
| 188 | /// never queries owners itself. |
| 189 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 190 | #[serde(rename_all = "snake_case")] |
| 191 | pub enum OperationObservation { |
| 192 | /// Owner is authoritative. Idempotency key = `(binding, seq)`. |
| 193 | OwnerReported { |
| 194 | state: OwnerState, |
| 195 | seq: u64, |
| 196 | at: Ts, |
| 197 | /// Bounded logical output receipt. The reference never contains raw |
| 198 | /// logs or reasoning; `raw_bytes` preserves the pre-truncation size. |
| 199 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 200 | output: Option<EvidenceRef>, |
| 201 | }, |
| 202 | /// No live handle for the binding (e.g. after restart). Never maps to |
| 203 | /// Active or Completed — only to Stale (fail toward honesty). |
| 204 | OwnerMissing { |
| 205 | checked_at: Ts, |
| 206 | }, |
| 207 | CancelUpdate { |
| 208 | outcome: CancelOutcome, |
| 209 | at: Ts, |
| 210 | }, |
| 211 | } |
| 212 | |
| 213 | /// Compact record of the most recent observation, stored on the binding. |
| 214 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 215 | pub struct ObservationSummary { |
| 216 | pub owner_state: OwnerState, |
| 217 | pub seq: u64, |
| 218 | pub observed_at: Ts, |
| 219 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 220 | pub output: Option<EvidenceRef>, |
| 221 | } |
| 222 | |
| 223 | /// Everything ambient the reducer needs, supplied by the caller so the |
| 224 | /// reducer itself stays pure: no clock reads, no RNG, no globals. |
| 225 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 226 | pub struct ChangeCtx { |
| 227 | /// Session identity used for deterministic ID derivation. |
| 228 | pub session_id: String, |
| 229 | /// Timestamp to record for this change (milliseconds since Unix epoch). |
| 230 | pub now: Ts, |
| 231 | /// Present for owner-observation changes; duplicates inside the |
| 232 | /// snapshot's dedup window become no-op receipts. |
| 233 | pub idempotency_key: Option<IdempotencyKey>, |
| 234 | } |
| 235 | |
| 236 | /// Receipt for an applied (or deduplicated) change. Bounded history of these |
| 237 | /// lives on the snapshot. Receipts carry discriminant names and identifiers |
| 238 | /// only — no payload text, no secrets. |
| 239 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 240 | pub struct ChangeReceipt { |
| 241 | pub change_id: ChangeId, |
| 242 | pub revision: u64, |
| 243 | pub summary: String, |
| 244 | pub applied_at: Ts, |
| 245 | pub idempotency_key: Option<IdempotencyKey>, |
| 246 | pub no_op: bool, |
| 247 | } |
| 248 | |
| 249 | impl ChangeReceipt { |
| 250 | #[must_use] |
| 251 | pub fn of(change: &WorkGraphChange, revision: u64, ctx: &ChangeCtx) -> Self { |
| 252 | let kind = change.kind_name(); |
| 253 | ChangeReceipt { |
| 254 | change_id: ChangeId::derive(&ctx.session_id, &format!("change:{revision}:{kind}")), |
| 255 | revision, |
| 256 | summary: kind.to_string(), |
| 257 | applied_at: ctx.now, |
| 258 | idempotency_key: ctx.idempotency_key.clone(), |
| 259 | no_op: false, |
| 260 | } |
| 261 | } |
| 262 | } |
| 263 |