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}