Skip to content

Commit 8a8ca8a

Browse files
committed
ROADMAP #4.44.5: Ship/provenance events — implement §4.44.5
Adds structured ship provenance surface to eliminate delivery-path opacity: New lane events: - ship.prepared — intent to ship established - ship.commits_selected — commit range locked - ship.merged — merge completed with provenance - ship.pushed_main — delivery to main confirmed ShipProvenance struct carries: - source_branch, base_commit - commit_count, commit_range - merge_method (direct_push/fast_forward/merge_commit/squash_merge/rebase_merge) - actor, pr_number Constructor methods added to LaneEvent for all four ship events. Tests: - Wire value serialization for ship events - Round-trip deserialization - Canonical event name coverage Runtime: 465 tests pass ROADMAP updated with IMPLEMENTED status This closes the gap where 56 commits pushed to main had no structured provenance trail — now emits first-class events for clawhip consumption.
1 parent b0b579e commit 8a8ca8a

3 files changed

Lines changed: 115 additions & 3 deletions

File tree

ROADMAP.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -711,7 +711,21 @@ Acceptance:
711711
- token-risk preflight becomes operational guidance, not just warning text
712712
- first-run users stop getting stuck between diagnosis and manual cleanup
713713

714-
### 4.44.5. Ship/provenance opacity — branch → merge → main-push boundary not first-class
714+
### 4.44.5. Ship/provenance opacity — IMPLEMENTED 2026-04-20
715+
716+
**Status:** Events implemented in `lane_events.rs`. Surface now emits structured ship provenance.
717+
718+
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.
719+
720+
**Implemented behavior:**
721+
- `ship.prepared` event — intent to ship established
722+
- `ship.commits_selected` event — commit range locked
723+
- `ship.merged` event — merge completed with metadata
724+
- `ship.pushed_main` event — delivery to main confirmed
725+
- All carry `ShipProvenance { source_branch, base_commit, commit_count, commit_range, merge_method, actor, pr_number }`
726+
- `ShipMergeMethod` enum: direct_push, fast_forward, merge_commit, squash_merge, rebase_merge
727+
728+
Required behavior:
715729

716730
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.
717731

rust/crates/runtime/src/lane_events.rs

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ pub enum LaneEventName {
3838
BranchStaleAgainstMain,
3939
#[serde(rename = "branch.workspace_mismatch")]
4040
BranchWorkspaceMismatch,
41+
/// Ship/provenance events — §4.44.5
42+
#[serde(rename = "ship.prepared")]
43+
ShipPrepared,
44+
#[serde(rename = "ship.commits_selected")]
45+
ShipCommitsSelected,
46+
#[serde(rename = "ship.merged")]
47+
ShipMerged,
48+
#[serde(rename = "ship.pushed_main")]
49+
ShipPushedMain,
4150
}
4251

4352
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -424,6 +433,29 @@ pub struct LaneCommitProvenance {
424433
pub lineage: Vec<String>,
425434
}
426435

436+
/// Ship/provenance metadata — §4.44.5
437+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
438+
pub struct ShipProvenance {
439+
pub source_branch: String,
440+
pub base_commit: String,
441+
pub commit_count: u32,
442+
pub commit_range: String,
443+
pub merge_method: ShipMergeMethod,
444+
pub actor: String,
445+
#[serde(skip_serializing_if = "Option::is_none")]
446+
pub pr_number: Option<u32>,
447+
}
448+
449+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
450+
#[serde(rename_all = "snake_case")]
451+
pub enum ShipMergeMethod {
452+
DirectPush,
453+
FastForward,
454+
MergeCommit,
455+
SquashMerge,
456+
RebaseMerge,
457+
}
458+
427459
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
428460
pub struct LaneEvent {
429461
pub event: LaneEventName,
@@ -527,6 +559,38 @@ impl LaneEvent {
527559
event
528560
}
529561

562+
/// Ship prepared — §4.44.5
563+
#[must_use]
564+
pub fn ship_prepared(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
565+
Self::new(LaneEventName::ShipPrepared, LaneEventStatus::Ready, emitted_at)
566+
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
567+
}
568+
569+
/// Ship commits selected — §4.44.5
570+
#[must_use]
571+
pub fn ship_commits_selected(
572+
emitted_at: impl Into<String>,
573+
commit_count: u32,
574+
commit_range: impl Into<String>,
575+
) -> Self {
576+
Self::new(LaneEventName::ShipCommitsSelected, LaneEventStatus::Ready, emitted_at)
577+
.with_detail(format!("{} commits: {}", commit_count, commit_range.into()))
578+
}
579+
580+
/// Ship merged — §4.44.5
581+
#[must_use]
582+
pub fn ship_merged(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
583+
Self::new(LaneEventName::ShipMerged, LaneEventStatus::Completed, emitted_at)
584+
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
585+
}
586+
587+
/// Ship pushed to main — §4.44.5
588+
#[must_use]
589+
pub fn ship_pushed_main(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
590+
Self::new(LaneEventName::ShipPushedMain, LaneEventStatus::Completed, emitted_at)
591+
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
592+
}
593+
530594
#[must_use]
531595
pub fn with_failure_class(mut self, failure_class: LaneFailureClass) -> Self {
532596
self.failure_class = Some(failure_class);
@@ -600,7 +664,8 @@ mod tests {
600664
compute_event_fingerprint, dedupe_superseded_commit_events, dedupe_terminal_events,
601665
is_terminal_event, BlockedSubphase, EventProvenance, LaneCommitProvenance, LaneEvent,
602666
LaneEventBlocker, LaneEventBuilder, LaneEventMetadata, LaneEventName, LaneEventStatus,
603-
LaneFailureClass, LaneOwnership, SessionIdentity, WatcherAction,
667+
LaneFailureClass, LaneOwnership, SessionIdentity, ShipMergeMethod, ShipProvenance,
668+
WatcherAction,
604669
};
605670

606671
#[test]
@@ -629,6 +694,10 @@ mod tests {
629694
LaneEventName::BranchWorkspaceMismatch,
630695
"branch.workspace_mismatch",
631696
),
697+
(LaneEventName::ShipPrepared, "ship.prepared"),
698+
(LaneEventName::ShipCommitsSelected, "ship.commits_selected"),
699+
(LaneEventName::ShipMerged, "ship.merged"),
700+
(LaneEventName::ShipPushedMain, "ship.pushed_main"),
632701
];
633702

634703
for (event, expected) in cases {
@@ -718,6 +787,34 @@ mod tests {
718787
);
719788
}
720789

790+
#[test]
791+
fn ship_provenance_events_serialize_to_expected_wire_values() {
792+
let provenance = ShipProvenance {
793+
source_branch: "feature/provenance".to_string(),
794+
base_commit: "dd73962".to_string(),
795+
commit_count: 6,
796+
commit_range: "dd73962..c956f78".to_string(),
797+
merge_method: ShipMergeMethod::DirectPush,
798+
actor: "Jobdori".to_string(),
799+
pr_number: None,
800+
};
801+
802+
let prepared = LaneEvent::ship_prepared("2026-04-20T14:30:00Z", &provenance);
803+
let prepared_json = serde_json::to_value(&prepared).expect("ship event should serialize");
804+
assert_eq!(prepared_json["event"], "ship.prepared");
805+
assert_eq!(prepared_json["data"]["commit_count"], 6);
806+
assert_eq!(prepared_json["data"]["source_branch"], "feature/provenance");
807+
808+
let pushed = LaneEvent::ship_pushed_main("2026-04-20T14:35:00Z", &provenance);
809+
let pushed_json = serde_json::to_value(&pushed).expect("ship event should serialize");
810+
assert_eq!(pushed_json["event"], "ship.pushed_main");
811+
assert_eq!(pushed_json["data"]["merge_method"], "direct_push");
812+
813+
let round_trip: LaneEvent =
814+
serde_json::from_value(pushed_json).expect("ship event should deserialize");
815+
assert_eq!(round_trip.event, LaneEventName::ShipPushedMain);
816+
}
817+
721818
#[test]
722819
fn commit_events_can_carry_worktree_and_supersession_metadata() {
723820
let event = LaneEvent::commit_created(

rust/crates/runtime/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ pub use lane_events::{
8686
compute_event_fingerprint, dedupe_superseded_commit_events, dedupe_terminal_events,
8787
is_terminal_event, BlockedSubphase, EventProvenance, LaneCommitProvenance, LaneEvent,
8888
LaneEventBlocker, LaneEventBuilder, LaneEventMetadata, LaneEventName, LaneEventStatus,
89-
LaneFailureClass, LaneOwnership, SessionIdentity, WatcherAction,
89+
LaneFailureClass, LaneOwnership, SessionIdentity, ShipMergeMethod, ShipProvenance,
90+
WatcherAction,
9091
};
9192
pub use mcp::{
9293
mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp,

0 commit comments

Comments
 (0)