feat(org-ops-core): add ChronicleClient for CloudEvents emission

Replaces fake Forgejo push webhook pattern with structured
CloudEvents 1.0. Git-originated events use commit SHA as event id.
Non-git events use UUID v4.

No new dependencies — constructs CloudEvents JSON manually using
serde_json. Endpoint auto-derived from legacy webhook URL.

Ref: cid-reconciliation-audit.md Phase 1

Signed-off-by: Tyler King <tking@guildhouse.dev>
This commit is contained in:
Tyler J King 2026-04-12 06:45:19 -04:00
parent d39fd692eb
commit cf744dd909
2 changed files with 234 additions and 0 deletions

View file

@ -0,0 +1,233 @@
// Copyright 2026 Guildhouse Dev
// SPDX-License-Identifier: Apache-2.0
//! CloudEvents 1.0 Chronicle emitter.
//!
//! Replaces the fake Forgejo push webhook pattern used previously.
//! Git-originated events use the commit SHA as the CloudEvent `id`.
//! Non-git events use a UUID v4.
use std::time::Duration;
/// CloudEvents type prefix for Guildhouse Chronicle events.
const CE_TYPE_PREFIX: &str = "dev.guildhouse.chronicle.";
/// CloudEvents 1.0 Chronicle emitter.
///
/// Emits structured governance events to the Chronicle webhook receiver's
/// `/webhook/cloudevents` endpoint. Each event is a valid CloudEvents 1.0
/// JSON envelope.
pub struct ChronicleClient {
endpoint: String,
http: reqwest::blocking::Client,
}
impl ChronicleClient {
/// Create a new client targeting the given CloudEvents endpoint.
///
/// The `endpoint` should be the full URL including path, e.g.
/// `http://chronicle-receiver:8090/webhook/cloudevents`.
pub fn new(endpoint: &str) -> Self {
Self {
endpoint: endpoint.to_string(),
http: reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.expect("HTTP client"),
}
}
/// Derive the CloudEvents endpoint from a legacy webhook URL.
///
/// The old pattern used `/webhook/forgejo`. This replaces the path
/// component with `/webhook/cloudevents`.
pub fn from_legacy_webhook(webhook_url: &str) -> Self {
let endpoint = if let Some(base) = webhook_url.rfind("/webhook/") {
format!("{}/webhook/cloudevents", &webhook_url[..base])
} else {
// No /webhook/ path found — append /webhook/cloudevents
format!("{}/webhook/cloudevents", webhook_url.trim_end_matches('/'))
};
Self::new(&endpoint)
}
/// Emit a governance event to Chronicle via CloudEvents 1.0.
///
/// # Arguments
///
/// * `kind` — Event kind, e.g. `"GOV_COMMIT_CREATED"`. Will be appended
/// to the type prefix `dev.guildhouse.chronicle.`.
/// * `source` — Actor DID, e.g. `"did:web:guildhouse.dev:user:tking"`.
/// * `event_id` — For git events, the commit SHA. For non-git events,
/// call [`Self::generate_id()`] for a UUID v4.
/// * `data` — Structured event payload. Must include a `"kind"` field
/// matching the `kind` argument (the receiver uses `data.kind` first).
pub fn emit(
&self,
kind: &str,
source: &str,
event_id: &str,
data: serde_json::Value,
) -> bool {
let envelope = serde_json::json!({
"specversion": "1.0",
"type": format!("{}{}", CE_TYPE_PREFIX, kind),
"source": source,
"id": event_id,
"time": now_rfc3339(),
"datacontenttype": "application/json",
"data": data,
});
self.http
.post(&self.endpoint)
.header("Content-Type", "application/cloudevents+json; charset=utf-8")
.json(&envelope)
.send()
.map(|r| r.status().is_success())
.unwrap_or(false)
}
/// Generate a UUID v4 event ID for non-git events.
pub fn generate_id() -> String {
uuid::Uuid::new_v4().to_string()
}
}
/// RFC 3339 timestamp for CloudEvents `time` field.
fn now_rfc3339() -> String {
let dur = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let secs = dur.as_secs();
// Simple UTC RFC 3339 without pulling in chrono.
// Format: days since epoch -> y/m/d, remaining secs -> h:m:s
let days = secs / 86400;
let time_secs = secs % 86400;
let h = time_secs / 3600;
let m = (time_secs % 3600) / 60;
let s = time_secs % 60;
// days -> year/month/day (simplified Gregorian)
let (y, mo, d) = days_to_ymd(days);
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
}
/// Convert days since Unix epoch to (year, month, day).
fn days_to_ymd(days: u64) -> (u64, u64, u64) {
// Algorithm from Howard Hinnant's chrono-compatible date library.
let z = days + 719468;
let era = z / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if mo <= 2 { y + 1 } else { y };
(y, mo, d)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cloudevents_envelope_git_event() {
// Verify a git-originated event produces valid CloudEvents 1.0 JSON
let commit_sha = "abc123def456abc123def456abc123def456abc1";
let data = serde_json::json!({
"kind": "GOV_COMMIT_CREATED",
"description": "sha=abc123 msg=test commit",
"repo": "tking/example",
"git_ref": "refs/heads/main",
});
let envelope = serde_json::json!({
"specversion": "1.0",
"type": format!("{}GOV_COMMIT_CREATED", CE_TYPE_PREFIX),
"source": "did:web:guildhouse.dev:user:tking",
"id": commit_sha,
"time": now_rfc3339(),
"datacontenttype": "application/json",
"data": data,
});
// Required CloudEvents 1.0 fields
assert_eq!(envelope["specversion"], "1.0");
assert!(envelope["type"].as_str().unwrap().starts_with("dev.guildhouse.chronicle."));
assert!(!envelope["source"].as_str().unwrap().is_empty());
assert_eq!(envelope["id"], commit_sha);
assert!(envelope["time"].as_str().unwrap().ends_with('Z'));
assert!(envelope["data"]["kind"].as_str().is_some());
}
#[test]
fn test_cloudevents_envelope_non_git_event() {
// Verify a non-git event uses UUID as id
let event_id = ChronicleClient::generate_id();
let data = serde_json::json!({
"kind": "GOV_PLAYBOOK_STARTED",
"playbook": "deploy-web",
"target": "all",
});
let envelope = serde_json::json!({
"specversion": "1.0",
"type": format!("{}GOV_PLAYBOOK_STARTED", CE_TYPE_PREFIX),
"source": "did:web:guildhouse.dev:user:operator",
"id": &event_id,
"time": now_rfc3339(),
"datacontenttype": "application/json",
"data": data,
});
assert_eq!(envelope["specversion"], "1.0");
assert!(envelope["type"].as_str().unwrap().contains("GOV_PLAYBOOK_STARTED"));
// UUID v4 is 36 chars with hyphens
assert_eq!(event_id.len(), 36);
assert!(event_id.contains('-'));
assert!(envelope["data"]["kind"].as_str().is_some());
}
#[test]
fn test_from_legacy_webhook() {
let client = ChronicleClient::from_legacy_webhook(
"http://localhost:8090/webhook/forgejo",
);
assert_eq!(client.endpoint, "http://localhost:8090/webhook/cloudevents");
let client2 = ChronicleClient::from_legacy_webhook(
"http://chronicle:8090/webhook/forgejo",
);
assert_eq!(client2.endpoint, "http://chronicle:8090/webhook/cloudevents");
}
#[test]
fn test_now_rfc3339_format() {
let ts = now_rfc3339();
// Should match YYYY-MM-DDTHH:MM:SSZ
assert_eq!(ts.len(), 20);
assert_eq!(&ts[4..5], "-");
assert_eq!(&ts[7..8], "-");
assert_eq!(&ts[10..11], "T");
assert_eq!(&ts[13..14], ":");
assert_eq!(&ts[16..17], ":");
assert!(ts.ends_with('Z'));
}
#[test]
fn test_days_to_ymd_epoch() {
// 1970-01-01
let (y, m, d) = days_to_ymd(0);
assert_eq!((y, m, d), (1970, 1, 1));
}
#[test]
fn test_days_to_ymd_known_date() {
// 2026-04-12 = day 20555 since epoch
let (y, m, d) = days_to_ymd(20555);
assert_eq!((y, m, d), (2026, 4, 12));
}
}

View file

@ -9,6 +9,7 @@
pub mod ai_risk_analysis; pub mod ai_risk_analysis;
pub mod apply_gate; pub mod apply_gate;
pub mod auth_commands; pub mod auth_commands;
pub mod chronicle_client;
pub mod config; pub mod config;
pub mod test_evidence; pub mod test_evidence;
pub mod display; pub mod display;