diff --git a/ROADMAP.md b/ROADMAP.md index 35e2778..5891e07 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -711,7 +711,21 @@ Acceptance: - token-risk preflight becomes operational guidance, not just warning text - first-run users stop getting stuck between diagnosis and manual cleanup -### 4.44.5. Ship/provenance opacity — branch → merge → main-push boundary not first-class +### 4.44.5. Ship/provenance opacity — IMPLEMENTED 2026-04-20 + +**Status:** Events implemented in `lane_events.rs`. Surface now emits structured ship provenance. + +When dogfood work lands on `main`, the delivery path (scoped branch → PR → merge → push vs direct push) and the exact commit set shipped are not surfaced as first-class events. This makes it too easy to lose the boundary between "dogfood fix landed", "what exact commits shipped", and "what review/merge path was actually used." The 56-commit push during 2026-04-20 dogfood (#122/#127/#129/#130/#131/#132) exhibited this gap: work started as scoped pinpoint branches, then collapsed into a direct `origin/main` push with no structured provenance trail. + +**Implemented behavior:** +- `ship.prepared` event — intent to ship established +- `ship.commits_selected` event — commit range locked +- `ship.merged` event — merge completed with metadata +- `ship.pushed_main` event — delivery to main confirmed +- All carry `ShipProvenance { source_branch, base_commit, commit_count, commit_range, merge_method, actor, pr_number }` +- `ShipMergeMethod` enum: direct_push, fast_forward, merge_commit, squash_merge, rebase_merge + +Required behavior: When dogfood work lands on `main`, the delivery path (scoped branch → PR → merge → push vs direct push) and the exact commit set shipped are not surfaced as first-class events. This makes it too easy to lose the boundary between "dogfood fix landed", "what exact commits shipped", and "what review/merge path was actually used." The 56-commit push during 2026-04-20 dogfood (#122/#127/#129/#130/#131/#132) exhibited this gap: work started as scoped pinpoint branches, then collapsed into a direct `origin/main` push with no structured provenance trail. diff --git a/rust/crates/runtime/src/lane_events.rs b/rust/crates/runtime/src/lane_events.rs index d5d709d..2dcb042 100644 --- a/rust/crates/runtime/src/lane_events.rs +++ b/rust/crates/runtime/src/lane_events.rs @@ -38,6 +38,15 @@ pub enum LaneEventName { BranchStaleAgainstMain, #[serde(rename = "branch.workspace_mismatch")] BranchWorkspaceMismatch, + /// Ship/provenance events — §4.44.5 + #[serde(rename = "ship.prepared")] + ShipPrepared, + #[serde(rename = "ship.commits_selected")] + ShipCommitsSelected, + #[serde(rename = "ship.merged")] + ShipMerged, + #[serde(rename = "ship.pushed_main")] + ShipPushedMain, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -424,6 +433,29 @@ pub struct LaneCommitProvenance { pub lineage: Vec, } +/// Ship/provenance metadata — §4.44.5 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ShipProvenance { + pub source_branch: String, + pub base_commit: String, + pub commit_count: u32, + pub commit_range: String, + pub merge_method: ShipMergeMethod, + pub actor: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub pr_number: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ShipMergeMethod { + DirectPush, + FastForward, + MergeCommit, + SquashMerge, + RebaseMerge, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LaneEvent { pub event: LaneEventName, @@ -527,6 +559,38 @@ impl LaneEvent { event } + /// Ship prepared — §4.44.5 + #[must_use] + pub fn ship_prepared(emitted_at: impl Into, provenance: &ShipProvenance) -> Self { + Self::new(LaneEventName::ShipPrepared, LaneEventStatus::Ready, emitted_at) + .with_data(serde_json::to_value(provenance).expect("ship provenance should serialize")) + } + + /// Ship commits selected — §4.44.5 + #[must_use] + pub fn ship_commits_selected( + emitted_at: impl Into, + commit_count: u32, + commit_range: impl Into, + ) -> Self { + Self::new(LaneEventName::ShipCommitsSelected, LaneEventStatus::Ready, emitted_at) + .with_detail(format!("{} commits: {}", commit_count, commit_range.into())) + } + + /// Ship merged — §4.44.5 + #[must_use] + pub fn ship_merged(emitted_at: impl Into, provenance: &ShipProvenance) -> Self { + Self::new(LaneEventName::ShipMerged, LaneEventStatus::Completed, emitted_at) + .with_data(serde_json::to_value(provenance).expect("ship provenance should serialize")) + } + + /// Ship pushed to main — §4.44.5 + #[must_use] + pub fn ship_pushed_main(emitted_at: impl Into, provenance: &ShipProvenance) -> Self { + Self::new(LaneEventName::ShipPushedMain, LaneEventStatus::Completed, emitted_at) + .with_data(serde_json::to_value(provenance).expect("ship provenance should serialize")) + } + #[must_use] pub fn with_failure_class(mut self, failure_class: LaneFailureClass) -> Self { self.failure_class = Some(failure_class); @@ -600,7 +664,8 @@ mod tests { compute_event_fingerprint, dedupe_superseded_commit_events, dedupe_terminal_events, is_terminal_event, BlockedSubphase, EventProvenance, LaneCommitProvenance, LaneEvent, LaneEventBlocker, LaneEventBuilder, LaneEventMetadata, LaneEventName, LaneEventStatus, - LaneFailureClass, LaneOwnership, SessionIdentity, WatcherAction, + LaneFailureClass, LaneOwnership, SessionIdentity, ShipMergeMethod, ShipProvenance, + WatcherAction, }; #[test] @@ -629,6 +694,10 @@ mod tests { LaneEventName::BranchWorkspaceMismatch, "branch.workspace_mismatch", ), + (LaneEventName::ShipPrepared, "ship.prepared"), + (LaneEventName::ShipCommitsSelected, "ship.commits_selected"), + (LaneEventName::ShipMerged, "ship.merged"), + (LaneEventName::ShipPushedMain, "ship.pushed_main"), ]; for (event, expected) in cases { @@ -718,6 +787,34 @@ mod tests { ); } + #[test] + fn ship_provenance_events_serialize_to_expected_wire_values() { + let provenance = ShipProvenance { + source_branch: "feature/provenance".to_string(), + base_commit: "dd73962".to_string(), + commit_count: 6, + commit_range: "dd73962..c956f78".to_string(), + merge_method: ShipMergeMethod::DirectPush, + actor: "Jobdori".to_string(), + pr_number: None, + }; + + let prepared = LaneEvent::ship_prepared("2026-04-20T14:30:00Z", &provenance); + let prepared_json = serde_json::to_value(&prepared).expect("ship event should serialize"); + assert_eq!(prepared_json["event"], "ship.prepared"); + assert_eq!(prepared_json["data"]["commit_count"], 6); + assert_eq!(prepared_json["data"]["source_branch"], "feature/provenance"); + + let pushed = LaneEvent::ship_pushed_main("2026-04-20T14:35:00Z", &provenance); + let pushed_json = serde_json::to_value(&pushed).expect("ship event should serialize"); + assert_eq!(pushed_json["event"], "ship.pushed_main"); + assert_eq!(pushed_json["data"]["merge_method"], "direct_push"); + + let round_trip: LaneEvent = + serde_json::from_value(pushed_json).expect("ship event should deserialize"); + assert_eq!(round_trip.event, LaneEventName::ShipPushedMain); + } + #[test] fn commit_events_can_carry_worktree_and_supersession_metadata() { let event = LaneEvent::commit_created( diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index 217c7f4..c7d8709 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -86,7 +86,8 @@ pub use lane_events::{ compute_event_fingerprint, dedupe_superseded_commit_events, dedupe_terminal_events, is_terminal_event, BlockedSubphase, EventProvenance, LaneCommitProvenance, LaneEvent, LaneEventBlocker, LaneEventBuilder, LaneEventMetadata, LaneEventName, LaneEventStatus, - LaneFailureClass, LaneOwnership, SessionIdentity, WatcherAction, + LaneFailureClass, LaneOwnership, SessionIdentity, ShipMergeMethod, ShipProvenance, + WatcherAction, }; pub use mcp::{ mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp,