Gunnar

mikronova/funnel-core

src/event.rs

//! Append-only event log entries that materialise the funnel state.
//!
//! Every mutation to the funnel — submitting an idea, adding a plan
//! node, flipping a status, recording a run — is serialised as a
//! single [`Event`] appended as a row to the Iceberg `funnel_events` table
//! under `<workspace>/.nornir/warehouse` (one snapshot per write).
//! Loading the funnel = replaying every row into [`super::state::Funnel`].
//!
//! Events are versioned implicitly by serde's `#[serde(tag = "kind")]`
//! discriminator. New event kinds can be added at the bottom; old rows
//! keep replaying cleanly as long as renames go through `#[serde(rename)]`.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

use super::ids::{IdeaId, NodeId, PlanId, RunId};

/// Where an idea originated. Free-form so MCP / CI integrations can
/// add their own source tags without a schema bump.
pub type Source = String;

/// File-glob style target marker. Empty means "no target".
pub type Target = String;

/// Lifecycle of a plan node.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NodeStatus {
    /// Created but deps not yet satisfied.
    Pending,
    /// All deps done; eligible for `topo_ready()`.
    Ready,
    /// Someone (human or agent) picked it up.
    InProgress,
    /// Successfully completed; dependents may now be `Ready`.
    Done,
    /// Cannot proceed; dependents remain `Pending` forever unless
    /// re-planned with an alternate route.
    Blocked,
    /// Attempted and failed; can be re-attempted (a new `Run` will
    /// be recorded — node stays `Failed` until explicitly flipped).
    Failed,
}

/// Lifecycle of a plan.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlanStatus {
    Draft,
    Active,
    Done,
    Abandoned,
}

/// What kind of intake a funnel item is. An **idea** is a feature/krav/prompt;
/// an **error** is a pasted error report / bug; a **test** is a test-idea (a
/// missing/desired test). All three are first-class funnel items (EPIC
/// FUNNEL-INTAKE / FI3 + the funnel-planning extension): each triages → a plan
/// node exactly the same way, only the `node_kind` the auto-planner stamps
/// differs (feature / error-fix / test). Serialised lower-case
/// (`idea` / `error` / `test`) into the `funnel_events` `item_kind` column.
/// `Idea` is the default so every pre-FI row (no column / null) reads back as
/// an idea.
///
/// **Open enum** (`#[non_exhaustive]`) — this vocabulary has already grown once
/// (idea → +error → +test) and [`parse`](ItemKind::parse) deliberately fails open
/// to `Idea`, so a new intake kind is an additive change consumers must survive.
/// Use the total accessors [`as_str`](ItemKind::as_str) /
/// [`node_kind`](ItemKind::node_kind) instead of matching where you can.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ItemKind {
    /// A feature request / krav / prompt — the original funnel intake.
    #[default]
    Idea,
    /// A pasted error report / bug — captured the same way, triageable to a plan.
    Error,
    /// A test-idea — a desired/missing test. Planned into test node(s) on the
    /// affected component(s), exactly like an idea/error (the auto-planner just
    /// stamps a `test:*` node kind).
    Test,
}

impl ItemKind {
    /// The lower-case wire/CLI string (`idea` | `error` | `test`).
    pub fn as_str(&self) -> &'static str {
        match self {
            ItemKind::Idea => "idea",
            ItemKind::Error => "error",
            ItemKind::Test => "test",
        }
    }

    /// Parse the wire/CLI string; unknown / empty → `Idea` (the safe default so
    /// legacy rows with no `item_kind` replay as ideas).
    pub fn parse(s: &str) -> Self {
        match s {
            "error" => ItemKind::Error,
            "test" => ItemKind::Test,
            _ => ItemKind::Idea,
        }
    }

    /// The `node_kind` the auto-planner stamps on a plan node derived from an
    /// item of this kind: a feature item → `code:write`, an error → `code:fix`,
    /// a test-idea → `test:write`. (Free verbs, matching the existing CLI
    /// convention — see `nornir funnel node`.)
    pub fn node_kind(&self) -> &'static str {
        match self {
            ItemKind::Idea => "code:write",
            ItemKind::Error => "code:fix",
            ItemKind::Test => "test:write",
        }
    }
}

/// Triage decision on an incoming idea.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TriageDecision {
    Accept,
    Drop,
}

/// Outcome of a single execution attempt.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunOutcome {
    Ok,
    Failed,
    Aborted,
}

/// Discriminated union of every mutation the funnel understands.
///
/// **Append-only.** Once a variant ships, it must keep deserialising
/// from older logs. Add new variants at the bottom; never remove or
/// reorder existing ones.
///
/// # This is an OPEN enum — consumers must handle unknown variants
///
/// `Event` is `#[non_exhaustive]`. Growth is the whole design: the funnel log is
/// append-only and every new capability arrives as a new variant, so **a new
/// variant is a routine, non-breaking addition and downstream code must be
/// written to survive one**. Concretely, every `match` on an `Event` outside this
/// crate needs a wildcard arm.
///
/// This was not free advice. `PlanComponentWired` (the wiring seam) landed while
/// `Event` was still a closed enum and instantly broke nornir's two exhaustive
/// matches in `warehouse/funnel.rs` (E0004), and through it
/// `dwarves --features embed-nornir`. `#[non_exhaustive]` makes that class of
/// outage impossible: the wildcard arm is now required up front, once.
///
/// **What a wildcard arm must do.** Fail loudly on the *write* path and skip
/// quietly on the *read* path:
///
/// * **Writing** (an event → a storage row): a variant this build cannot
///   serialise must `return Err(..)`, never be dropped. A silently unwritten
///   event is data loss the log can never recover.
/// * **Reading** (a row → an event) and **projecting**: an unknown kind comes
///   from a *newer* writer, so skip it with a warning and keep going. A hard
///   error there makes an old reader unable to open a new log at all.
///
/// **Prefer not to match.** Most consumers only want the discriminator string;
/// [`Event::kind_str`] is a total accessor that never needs updating, and
/// [`Event::KINDS`] is the full token set. Use those and a new variant costs the
/// consumer nothing at all.
///
/// The same open-enum contract applies to [`ItemKind`], [`CardType`](crate::card::CardType),
/// [`FunnelMode`](crate::mode::FunnelMode), [`ComponentEdgeKind`](crate::wiring::ComponentEdgeKind),
/// [`WireOrigin`](crate::wiring::WireOrigin) and [`UnmappedReason`](crate::wiring::UnmappedReason).
/// Deliberately **closed** (a new variant there is a genuine semantic break that
/// *should* stop a consumer's build until a human reads it): [`NodeStatus`],
/// [`PlanStatus`], [`TriageDecision`], [`RunOutcome`],
/// [`WireStatus`](crate::wiring::WireStatus), [`DagStatus`](crate::plan_dag::DagStatus),
/// [`EdgeKind`](crate::plan_dag::EdgeKind), [`Lane`](crate::card::Lane),
/// [`HistoryStatus`](crate::history::HistoryStatus) and [`Sort`](crate::card::Sort).
///
/// ```compile_fail
/// // A match over `Event` with no wildcard arm does not compile from outside
/// // funnel-core — even one that lists every variant that exists today. That is
/// // the contract: your code keeps compiling when the next variant lands.
/// # use funnel_core::Event;
/// fn describe(ev: &Event) -> &'static str {
///     match ev {
///         Event::IdeaSubmitted { .. } => "submitted",
///         Event::IdeaTriaged { .. } => "triaged",
///         Event::PlanCreated { .. } => "planned",
///         Event::NodeAdded { .. } => "node",
///         Event::EdgeAdded { .. } => "edge",
///         Event::NodeStatusChanged { .. } => "node-status",
///         Event::RunRecorded { .. } => "run",
///         Event::PlanStatusChanged { .. } => "plan-status",
///         Event::IdeaModeSet { .. } => "mode",
///         Event::CardAssigned { .. } => "assigned",
///         Event::CardStateChanged { .. } => "lane",
///         Event::PlanComponentWired { .. } => "wired",
///     }
/// }
/// ```
///
/// ```
/// // Add the wildcard arm and it compiles — the shape every consumer wants.
/// # use funnel_core::Event;
/// fn describe(ev: &Event) -> &'static str {
///     match ev {
///         Event::IdeaSubmitted { .. } => "submitted",
///         _ => ev.kind_str(),
///     }
/// }
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind")]
#[non_exhaustive]
pub enum Event {
    IdeaSubmitted {
        id: IdeaId,
        source: Source,
        text: String,
        #[serde(default)]
        refs: Vec<String>,
        /// `idea` (default) or `error` — an error report is a first-class funnel
        /// item triageable to a plan like any idea. `serde` default keeps
        /// pre-FI logs (no field) replaying as ideas.
        #[serde(default)]
        item_kind: ItemKind,
        ts: DateTime<Utc>,
    },
    IdeaTriaged {
        idea_id: IdeaId,
        decision: TriageDecision,
        #[serde(default)]
        why: Option<String>,
        ts: DateTime<Utc>,
    },
    PlanCreated {
        id: PlanId,
        idea_id: IdeaId,
        summary: String,
        planner: String,
        ts: DateTime<Utc>,
    },
    NodeAdded {
        plan_id: PlanId,
        node_id: NodeId,
        #[serde(rename = "node_kind")]
        kind: String,
        #[serde(default)]
        params: serde_json::Map<String, serde_json::Value>,
        #[serde(default)]
        targets: Vec<Target>,
        #[serde(default)]
        prompt_excerpt: Option<String>,
        ts: DateTime<Utc>,
    },
    EdgeAdded {
        plan_id: PlanId,
        from_node: NodeId,
        to_node: NodeId,
        ts: DateTime<Utc>,
    },
    NodeStatusChanged {
        plan_id: PlanId,
        node_id: NodeId,
        status: NodeStatus,
        #[serde(default)]
        why: Option<String>,
        ts: DateTime<Utc>,
    },
    RunRecorded {
        plan_id: PlanId,
        node_id: NodeId,
        run_id: RunId,
        ran_by: String,
        outcome: RunOutcome,
        #[serde(default)]
        log_ref: Option<String>,
        #[serde(default)]
        produced_commits: Vec<CommitRef>,
        #[serde(default)]
        produced_test_runs: Vec<String>,
        ts: DateTime<Utc>,
    },
    PlanStatusChanged {
        plan_id: PlanId,
        status: PlanStatus,
        #[serde(default)]
        why: Option<String>,
        ts: DateTime<Utc>,
    },
    /// Record which **mode** (`auto` | `manual`) a mother idea's prompt-driven
    /// decomposition ran under (the funnel-prompt-planner keystone). Purely
    /// additive — a brand-new variant, so it never touches the shape of
    /// `IdeaSubmitted` and every pre-existing log keeps replaying unchanged
    /// (no `IdeaModeSet` row ⇒ `Idea::mode` stays `None`).
    IdeaModeSet {
        idea_id: IdeaId,
        /// Free-form wire token (`"auto"` | `"manual"`); kept as a plain
        /// `String` so a new mode never needs a schema bump.
        mode: String,
        ts: DateTime<Utc>,
    },
    /// Assign a **card** (a mother idea `i-NNN` OR a decomposed task node
    /// `n-NNN` — the funnel-prompt-planner keystone's real-person assignment,
    /// §3) to a team member, optionally recording the role the allocator
    /// picked them for and why.
    CardAssigned {
        /// The card id being assigned — an [`IdeaId`] or [`NodeId`] string;
        /// kept generic (not a typed id) because a card can be either.
        node: String,
        member: String,
        #[serde(default)]
        role: Option<String>,
        #[serde(default)]
        why: Option<String>,
        ts: DateTime<Utc>,
    },
    /// Move a **card** between kanban lanes (`inbox`/`accepted`/`proposed`/
    /// `planned`/`dropped`) — the Manual-mode staging gate (§2): a Proposed
    /// draft `accept`s into Planned or gets rejected into Dropped.
    CardStateChanged {
        /// The card id transitioning — see [`Event::CardAssigned::node`].
        node: String,
        #[serde(default)]
        from_lane: Option<String>,
        to_lane: String,
        #[serde(default)]
        why: Option<String>,
        ts: DateTime<Utc>,
    },
    /// Wire a **Plan-DAG node to a Component-DAG component** — the bipartite
    /// `plan_component_edges` link of the funnel↔code wiring layer (see
    /// [`crate::wiring`]): "this plan step touches that crate/module".
    ///
    /// **Last-write-wins per `(plan_node, component)` pair.** The log stays
    /// append-only — there is no un-wire event; a wire is retracted by appending a
    /// later row for the same pair with `status = "rejected"`, which preserves the
    /// fact that the suggestion was considered and thrown away (so an inference
    /// pass never re-proposes it).
    ///
    /// Both `origin` and `status` are free-form wire tokens, exactly like
    /// [`Event::IdeaModeSet::mode`], so a new provenance or lifecycle state never
    /// needs a schema bump; read them typed via
    /// [`Wire::origin_kind`](crate::wiring::Wire::origin_kind) /
    /// [`Wire::status_kind`](crate::wiring::Wire::status_kind), which fail closed
    /// (an unreadable token never binds).
    PlanComponentWired {
        /// The Plan-DAG node id — an idea card (`i-NNN`) or a task node
        /// (`n-NNN`); a generic `String` because a card can be either, matching
        /// [`Event::CardAssigned::node`].
        plan_node: String,
        /// The component id, in the component graph's own namespace (a repo /
        /// crate name such as `"funnel-core"`). Opaque to the funnel: it is
        /// **not** validated, because the funnel does not own that namespace —
        /// an id the component graph does not hold surfaces as a *dangling* wire
        /// in [`WiringReport`](crate::wiring::WiringReport) instead.
        component: String,
        /// `"drawn"` (a human dragged it) | `"inferred"` (Mímir suggested it) |
        /// `"declared"` (derived from the node's `targets`).
        #[serde(default)]
        origin: String,
        /// `"proposed"` | `"confirmed"` | `"rejected"`. Empty ⇒ the origin's
        /// natural status (drawn ⇒ confirmed, otherwise proposed).
        #[serde(default)]
        status: String,
        /// The suggester's confidence in `0.0 ..= 1.0`, when it had one. Never
        /// auto-confirms — it only ranks suggestions for a human.
        #[serde(default)]
        confidence: Option<f64>,
        #[serde(default)]
        why: Option<String>,
        ts: DateTime<Utc>,
    },
}

/// Identifies a commit a [`RunRecorded`] event produced.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommitRef {
    pub repo: String,
    pub sha: String,
}

impl Event {
    /// Every kind token this build knows, in variant declaration order.
    ///
    /// The vocabulary a consumer can validate a stored `kind` column against —
    /// and, because [`Event`] is an open enum, a set that **grows**: a token not
    /// in here came from a newer writer, which is a skip-with-a-warning, not an
    /// error.
    pub const KINDS: &'static [&'static str] = &[
        "IdeaSubmitted",
        "IdeaTriaged",
        "PlanCreated",
        "NodeAdded",
        "EdgeAdded",
        "NodeStatusChanged",
        "RunRecorded",
        "PlanStatusChanged",
        "IdeaModeSet",
        "CardAssigned",
        "CardStateChanged",
        "PlanComponentWired",
    ];

    /// The event's discriminator token — **byte-identical** to the `kind` field
    /// `serde` writes (`#[serde(tag = "kind")]`), so a row's `kind` column and a
    /// serialised event can never disagree.
    ///
    /// This is the total accessor that lets a consumer stop matching on [`Event`]
    /// altogether: a store writing a `kind` column, a log filter, a metrics label
    /// all want this string and nothing else, and it keeps working unchanged when
    /// the next variant lands. Prefer it over a hand-rolled `match`.
    #[must_use]
    pub fn kind_str(&self) -> &'static str {
        // Exhaustive on purpose: `#[non_exhaustive]` has no effect inside the
        // defining crate, so adding a variant without giving it a token is a
        // compile error HERE — which is exactly where it should be caught.
        match self {
            Event::IdeaSubmitted { .. } => "IdeaSubmitted",
            Event::IdeaTriaged { .. } => "IdeaTriaged",
            Event::PlanCreated { .. } => "PlanCreated",
            Event::NodeAdded { .. } => "NodeAdded",
            Event::EdgeAdded { .. } => "EdgeAdded",
            Event::NodeStatusChanged { .. } => "NodeStatusChanged",
            Event::RunRecorded { .. } => "RunRecorded",
            Event::PlanStatusChanged { .. } => "PlanStatusChanged",
            Event::IdeaModeSet { .. } => "IdeaModeSet",
            Event::CardAssigned { .. } => "CardAssigned",
            Event::CardStateChanged { .. } => "CardStateChanged",
            Event::PlanComponentWired { .. } => "PlanComponentWired",
        }
    }

    /// Timestamp this event was produced at. Used by the store for
    /// ordering when external tools replay the log out of order.
    pub fn ts(&self) -> DateTime<Utc> {
        match self {
            Event::IdeaSubmitted { ts, .. }
            | Event::IdeaTriaged { ts, .. }
            | Event::PlanCreated { ts, .. }
            | Event::NodeAdded { ts, .. }
            | Event::EdgeAdded { ts, .. }
            | Event::NodeStatusChanged { ts, .. }
            | Event::RunRecorded { ts, .. }
            | Event::PlanStatusChanged { ts, .. }
            | Event::IdeaModeSet { ts, .. }
            | Event::CardAssigned { ts, .. }
            | Event::CardStateChanged { ts, .. }
            | Event::PlanComponentWired { ts, .. } => *ts,
        }
    }
}