//! 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,
}
}
}