Skip to main content

ff_core/
capability.rs

1//! RFC-018 Stage A — backend capability discovery surface.
2//!
3//! Consumers of `Arc<dyn EngineBackend>` (and HTTP clients hitting
4//! `ff-server`) historically had no typed way to ask "what can this
5//! backend actually do?" before dispatching — they discovered
6//! capability gaps empirically, by trying a trait method and catching
7//! [`crate::engine_error::EngineError::Unavailable`]. This module
8//! adds a first-class discovery primitive: a [`Capability`] enum,
9//! [`CapabilityStatus`] shape, [`BackendIdentity`] tuple, and a
10//! [`CapabilityMatrix`] container that [`crate::engine_backend::EngineBackend`]
11//! exposes via `capabilities_matrix()`.
12//!
13//! Stage A (this module) is additive: the trait method has a default
14//! impl that returns an empty matrix tagged `family = "unknown"`, so
15//! out-of-tree backends keep compiling. Concrete in-tree backends
16//! (`ValkeyBackend`, `PostgresBackend`) override to report real caps.
17//!
18//! Stages B + C (follow-up PRs) derive `docs/POSTGRES_PARITY_MATRIX.md`
19//! from the runtime matrix and expose `GET /v1/capabilities` on
20//! `ff-server`.
21//!
22//! See `rfcs/RFC-018-backend-capability-discovery.md` for the full
23//! design, the four owner-adjudicated open questions, and the
24//! Alternatives-considered record.
25
26use std::collections::BTreeMap;
27
28/// Coarse-grained unit of functionality a backend may or may not
29/// provide. Granularity is one entry per operator-UI grey-renderable
30/// feature — not per trait method. See RFC-018 §9 Q1 for the
31/// owner adjudication (`coarse`, recommended by the draft).
32///
33/// `#[non_exhaustive]`: adding variants in future RFC-018 stages is
34/// a minor bump, not a break. Consumers matching on `Capability`
35/// must carry a wildcard arm.
36#[non_exhaustive]
37#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
38pub enum Capability {
39    // ── Claim + lifecycle ──
40    /// Scheduler-routed claim entrypoint
41    /// ([`EngineBackend::claim_for_worker`](crate::engine_backend::EngineBackend::claim_for_worker)).
42    ClaimForWorker,
43    /// Consume a reclaim grant to mint a resumed-kind handle.
44    ClaimFromReclaim,
45    /// Suspend + resume by buffered-signal count (RFC-013).
46    SuspendResumeByCount,
47
48    // ── Cancel ──
49    /// Operator-initiated single-execution cancel.
50    CancelExecution,
51    /// Operator-initiated flow cancel (no wait).
52    CancelFlow,
53    /// `cancel_flow` with `CancelFlowWait::WaitTimeout(Duration)`
54    /// (#298 driver).
55    CancelFlowWaitTimeout,
56    /// `cancel_flow` with `CancelFlowWait::WaitIndefinite`.
57    CancelFlowWaitIndefinite,
58
59    // ── Streams ──
60    /// `read_stream` (XRANGE-style bounded read).
61    StreamRead,
62    /// `tail_stream` (best-effort live tail).
63    StreamBestEffortLive,
64    /// Durable-summary frames + `read_summary`.
65    StreamDurableSummary,
66
67    // ── Signals + waitpoints ──
68    /// Deliver an external signal to a pending waitpoint.
69    DeliverSignal,
70    /// List pending-or-active waitpoints for an execution.
71    ListPendingWaitpoints,
72    /// Cluster-wide HMAC kid rotation.
73    RotateWaitpointHmac,
74    /// Idempotent initial HMAC-secret seed (#280).
75    SeedWaitpointHmac,
76
77    // ── Budget + quota ──
78    /// Worker-handle-path `report_usage`.
79    ReportUsage,
80    /// Admin-path `report_usage` (no worker handle; #297 driver).
81    ReportUsageAdminPath,
82    /// Reset a budget's usage counters.
83    ResetBudget,
84
85    // ── Ingress ──
86    /// Create a flow header.
87    CreateFlow,
88    /// Create an execution.
89    CreateExecution,
90    /// Stage a dependency edge.
91    StageDependencyEdge,
92    /// Apply a staged dependency edge to its downstream child.
93    ApplyDependencyToChild,
94
95    // ── Boot + subscriptions ──
96    /// Backend honours [`EngineBackend::prepare`](crate::engine_backend::EngineBackend::prepare)
97    /// with non-trivial work (e.g. Valkey `FUNCTION LOAD`).
98    PreparableBoot,
99    /// Subscribe to lease-epoch history for a handle.
100    SubscribeLeaseHistory,
101    /// Subscribe to completion notifications.
102    SubscribeCompletion,
103    /// Subscribe to signal-delivery notifications.
104    SubscribeSignalDelivery,
105
106    // ── Diagnostics ──
107    /// Backend-level reachability probe.
108    Ping,
109}
110
111/// Per-[`Capability`] support status reported by a concrete backend.
112///
113/// Consumers distinguish fully-supported from partially-gated
114/// ("works only with an extra setup step") and unsupported ("the
115/// trait method returns `EngineError::Unavailable` today") via
116/// these variants. `Unknown` is the safe default for pre-RFC-018
117/// backends that never populate a matrix row.
118///
119/// `#[non_exhaustive]`: future stages may add e.g. `SupportedSlow`
120/// or `Deprecated`; consumers must carry a wildcard arm.
121#[non_exhaustive]
122#[derive(Clone, Debug, Eq, PartialEq)]
123pub enum CapabilityStatus {
124    /// Backend fully supports this capability on every call.
125    Supported,
126    /// Backend does not support this capability; calling the
127    /// corresponding trait method returns `EngineError::Unavailable`.
128    Unsupported,
129    /// Backend supports this capability only under specific
130    /// configuration. The `note` explains the gating constraint in
131    /// a human-readable form suitable for UI surfacing.
132    Partial {
133        /// Human-readable hint describing the gating constraint
134        /// (e.g. "requires with_embedded_scheduler").
135        note: String,
136    },
137    /// Backend has not reported a status for this capability.
138    /// Consumers should treat as "dispatch and catch" — equivalent
139    /// to pre-RFC-018 behaviour.
140    Unknown,
141}
142
143/// Backend crate version. Kept as a struct (not a semver string) per
144/// RFC-018 §9 Q2: consumers can write
145/// `if backend.capabilities_matrix().identity.version >= Version::new(0, 10, 0) { .. }`
146/// without pulling a semver-parsing dep.
147#[non_exhaustive]
148#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
149pub struct Version {
150    /// Major version number.
151    pub major: u32,
152    /// Minor version number.
153    pub minor: u32,
154    /// Patch version number.
155    pub patch: u32,
156}
157
158impl Version {
159    /// Const constructor so concrete backends can declare a
160    /// `const` [`BackendIdentity`] without a function-call overhead
161    /// in `capabilities_matrix()`.
162    pub const fn new(major: u32, minor: u32, patch: u32) -> Self {
163        Self {
164            major,
165            minor,
166            patch,
167        }
168    }
169}
170
171/// Minimal-identity triple for a backend. Consumers that only need
172/// the family label + version (e.g. for metrics dimensioning) read
173/// this rather than the full [`CapabilityMatrix`].
174///
175/// `#[non_exhaustive]`: future stages may add fields (e.g. a
176/// backend-assigned `instance_id` or a `deployment_topology`
177/// hint); construct via the public constructor or struct literal on
178/// `Clone::clone` of an existing value.
179#[non_exhaustive]
180#[derive(Clone, Debug)]
181pub struct BackendIdentity {
182    /// Stable backend family name. `"valkey"`, `"postgres"`, or a
183    /// concrete string set by an out-of-tree backend. `"unknown"`
184    /// is the pre-RFC-018 default.
185    pub family: &'static str,
186    /// Backend crate version at build time.
187    pub version: Version,
188    /// RFC-017 migration stage this backend reports itself certified
189    /// at. One of `"A"`, `"B"`, `"C"`, `"D"`, `"E"`, `"E-shipped"`,
190    /// or `"unknown"` for backends that predate the RFC-017 staging.
191    pub rfc017_stage: &'static str,
192}
193
194impl BackendIdentity {
195    /// Direct-field constructor. Prefer this over struct-literal
196    /// syntax in consumer code: `#[non_exhaustive]` forbids literal
197    /// construction from outside the defining crate.
198    pub const fn new(
199        family: &'static str,
200        version: Version,
201        rfc017_stage: &'static str,
202    ) -> Self {
203        Self {
204            family,
205            version,
206            rfc017_stage,
207        }
208    }
209}
210
211/// Full capability snapshot for a backend: its [`BackendIdentity`]
212/// plus a stable-ordered map of [`Capability`] → [`CapabilityStatus`].
213///
214/// `BTreeMap` (not `HashMap`) so iteration order is deterministic —
215/// `ff-server`'s JSON response is byte-stable, cairn's operator UI
216/// can render rows in a fixed order, and tests comparing matrices
217/// across runs do not race on hash randomization.
218#[non_exhaustive]
219#[derive(Clone, Debug)]
220pub struct CapabilityMatrix {
221    /// Backend identity tuple this matrix was assembled for.
222    pub identity: BackendIdentity,
223    /// Per-capability status. Absent capabilities are treated as
224    /// [`CapabilityStatus::Unknown`] by [`Self::get`].
225    pub caps: BTreeMap<Capability, CapabilityStatus>,
226}
227
228impl CapabilityMatrix {
229    /// Build an empty matrix tagged with the given backend identity.
230    /// Backends populate rows via [`Self::set`] before returning the
231    /// matrix from `capabilities_matrix()`.
232    pub fn new(identity: BackendIdentity) -> Self {
233        Self {
234            identity,
235            caps: BTreeMap::new(),
236        }
237    }
238
239    /// Record the status for one capability. Returns `&mut self`
240    /// so backends can chain setup in a builder-style declaration.
241    pub fn set(&mut self, cap: Capability, status: CapabilityStatus) -> &mut Self {
242        self.caps.insert(cap, status);
243        self
244    }
245
246    /// Look up one capability's status. Absent rows return
247    /// [`CapabilityStatus::Unknown`] — callers that need to
248    /// distinguish "absent" from "explicitly marked unknown" must
249    /// consult `self.caps` directly.
250    pub fn get(&self, cap: Capability) -> CapabilityStatus {
251        self.caps
252            .get(&cap)
253            .cloned()
254            .unwrap_or(CapabilityStatus::Unknown)
255    }
256
257    /// Convenience predicate: the capability is
258    /// [`CapabilityStatus::Supported`] or [`CapabilityStatus::Partial`].
259    /// Both map to "you can call the trait method and it will work
260    /// (possibly with a caveat)"; `Unsupported` and `Unknown` both
261    /// map to "don't dispatch, or be ready to catch `Unavailable`."
262    pub fn supports(&self, cap: Capability) -> bool {
263        matches!(
264            self.get(cap),
265            CapabilityStatus::Supported | CapabilityStatus::Partial { .. }
266        )
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn version_new_is_const_and_ordered() {
276        const V: Version = Version::new(0, 9, 0);
277        assert_eq!(V.major, 0);
278        assert_eq!(V.minor, 9);
279        assert_eq!(V.patch, 0);
280        assert!(Version::new(0, 10, 0) > V);
281        assert!(Version::new(0, 9, 1) > V);
282        assert!(Version::new(1, 0, 0) > V);
283    }
284
285    #[test]
286    fn backend_identity_new_populates_fields() {
287        let id = BackendIdentity::new("valkey", Version::new(0, 9, 0), "E-shipped");
288        assert_eq!(id.family, "valkey");
289        assert_eq!(id.version, Version::new(0, 9, 0));
290        assert_eq!(id.rfc017_stage, "E-shipped");
291    }
292
293    #[test]
294    fn matrix_new_is_empty() {
295        let m = CapabilityMatrix::new(BackendIdentity::new(
296            "unknown",
297            Version::new(0, 0, 0),
298            "unknown",
299        ));
300        assert!(m.caps.is_empty());
301        // Unset capability resolves to Unknown.
302        assert_eq!(m.get(Capability::Ping), CapabilityStatus::Unknown);
303        assert!(!m.supports(Capability::Ping));
304    }
305
306    #[test]
307    fn matrix_set_get_supports() {
308        let mut m = CapabilityMatrix::new(BackendIdentity::new(
309            "valkey",
310            Version::new(0, 9, 0),
311            "E-shipped",
312        ));
313        m.set(Capability::Ping, CapabilityStatus::Supported)
314            .set(Capability::CancelExecution, CapabilityStatus::Unsupported)
315            .set(
316                Capability::ClaimForWorker,
317                CapabilityStatus::Partial {
318                    note: "requires with_embedded_scheduler".to_string(),
319                },
320            );
321
322        assert_eq!(m.get(Capability::Ping), CapabilityStatus::Supported);
323        assert!(m.supports(Capability::Ping));
324
325        assert_eq!(
326            m.get(Capability::CancelExecution),
327            CapabilityStatus::Unsupported
328        );
329        assert!(!m.supports(Capability::CancelExecution));
330
331        // Partial also reports as supported via the predicate.
332        assert!(m.supports(Capability::ClaimForWorker));
333        match m.get(Capability::ClaimForWorker) {
334            CapabilityStatus::Partial { note } => {
335                assert!(note.contains("with_embedded_scheduler"));
336            }
337            other => panic!("expected Partial, got {other:?}"),
338        }
339
340        // Chain returns &mut self so repeated .set() calls compose.
341        let rows = m.caps.len();
342        assert_eq!(rows, 3);
343    }
344
345    #[test]
346    fn matrix_set_overwrites_existing() {
347        let mut m = CapabilityMatrix::new(BackendIdentity::new(
348            "valkey",
349            Version::new(0, 9, 0),
350            "E-shipped",
351        ));
352        m.set(Capability::Ping, CapabilityStatus::Unsupported);
353        m.set(Capability::Ping, CapabilityStatus::Supported);
354        assert_eq!(m.get(Capability::Ping), CapabilityStatus::Supported);
355        assert_eq!(m.caps.len(), 1);
356    }
357}