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 [`Supports`] flat-bool
9//! struct, a [`BackendIdentity`] tuple, and a [`Capabilities`]
10//! container that [`crate::engine_backend::EngineBackend`] exposes
11//! via `capabilities()`.
12//!
13//! Stage A (this module) is additive: the trait method has a default
14//! impl that returns a `Capabilities` tagged `family = "unknown"` with
15//! every `supports.*` bool `false`, so out-of-tree backends keep
16//! compiling. Concrete in-tree backends (`ValkeyBackend`,
17//! `PostgresBackend`) override to report real caps.
18//!
19//! **Shape history.** v0.9 shipped a `BTreeMap<Capability,
20//! CapabilityStatus>` map; v0.10 reshaped to the flat [`Supports`]
21//! struct below per cairn's original #277 ask (flat named-field
22//! dot-access, no enum + no map lookup). `Partial`-status nuance
23//! (e.g. non-durable cursor on Valkey `subscribe_completion`) now
24//! lives in rustdoc on the trait method and
25//! `docs/POSTGRES_PARITY_MATRIX.md`; the flat bool answers "is this
26//! callable at all."
27//!
28//! Stages B + C (follow-up PRs) derive `docs/POSTGRES_PARITY_MATRIX.md`
29//! from the runtime value and expose `GET /v1/capabilities` on
30//! `ff-server`.
31//!
32//! See `rfcs/RFC-018-backend-capability-discovery.md` for the full
33//! design, the four owner-adjudicated open questions, and the
34//! Alternatives-considered record.
35
36/// Per-capability boolean support surface. Flat named-field shape so
37/// consumers can dot-access (e.g. `caps.supports.cancel_execution`)
38/// instead of map lookup. `#[non_exhaustive]` protects future
39/// additions from source-breaking consumers; construct via
40/// [`Supports::none`] or by returning one from
41/// [`crate::engine_backend::EngineBackend::capabilities`].
42///
43/// # Grouping policy
44///
45/// One bool per operator-visible HTTP surface; admin-only surfaces
46/// with many sibling methods roll up to a single bool (e.g.
47/// [`Self::budget_admin`] covers `create_budget` / `report_usage` /
48/// `reset_budget` / `get_budget_status` / `report_usage_admin`;
49/// [`Self::quota_admin`] covers `create_quota_policy`). Cairn's
50/// operator UI grey-renders at the group level; fine-grained
51/// pre-dispatch checks still use
52/// [`crate::engine_error::EngineError::Unavailable`].
53///
54/// # Field order
55///
56/// Per cairn #277 "in the same order as the parity matrix so cairn
57/// can consume by copy-paste." Keep in sync with
58/// `docs/POSTGRES_PARITY_MATRIX.md`.
59#[non_exhaustive]
60#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
61pub struct Supports {
62 // ── Execution operator control ──
63 /// `cancel_execution`.
64 pub cancel_execution: bool,
65 /// `change_priority`.
66 pub change_priority: bool,
67 /// `replay_execution`.
68 pub replay_execution: bool,
69 /// `revoke_lease`.
70 pub revoke_lease: bool,
71
72 // ── Execution reads ──
73 /// `read_execution_state`.
74 pub read_execution_state: bool,
75 /// `read_execution_info`.
76 pub read_execution_info: bool,
77 /// `get_execution_result`.
78 pub get_execution_result: bool,
79
80 // ── Budget + quota (group-level, rolls up siblings) ──
81 /// Covers `create_budget` + `report_usage` + `reset_budget` +
82 /// `get_budget_status` + `report_usage_admin`.
83 pub budget_admin: bool,
84 /// Covers `create_quota_policy`.
85 pub quota_admin: bool,
86
87 // ── Waitpoint admin ──
88 /// `rotate_waitpoint_hmac_secret_all`.
89 pub rotate_waitpoint_hmac_secret_all: bool,
90 /// `seed_waitpoint_hmac_secret` (#280).
91 pub seed_waitpoint_hmac_secret: bool,
92 /// `list_pending_waitpoints`.
93 pub list_pending_waitpoints: bool,
94
95 // ── Flow control ──
96 /// `cancel_flow_header`.
97 pub cancel_flow_header: bool,
98 /// `cancel_flow` with `CancelFlowWait::WaitTimeout(..)`.
99 pub cancel_flow_wait_timeout: bool,
100 /// `cancel_flow` with `CancelFlowWait::WaitIndefinite`.
101 pub cancel_flow_wait_indefinite: bool,
102 /// `ack_cancel_member`.
103 pub ack_cancel_member: bool,
104
105 // ── Scheduler ──
106 /// `claim_for_worker` (requires a wired scheduler on Valkey;
107 /// Postgres-native on Postgres).
108 pub claim_for_worker: bool,
109
110 // ── Boot ──
111 /// `prepare` does non-trivial work (e.g. Valkey `FUNCTION LOAD`).
112 /// Postgres reports `false` — `prepare` returns `NoOp` there.
113 pub prepare: bool,
114
115 // ── Stream subscriptions (RFC-019) ──
116 /// `subscribe_lease_history`.
117 pub subscribe_lease_history: bool,
118 /// `subscribe_completion`. On Valkey this is pubsub-backed
119 /// (non-durable cursor, at-most-once over the live subscription
120 /// window); Postgres is durable via outbox + cursor. Both report
121 /// `true`; see the trait method rustdoc for the non-durable-cursor
122 /// caveat and `docs/POSTGRES_PARITY_MATRIX.md` for the per-backend
123 /// semantic.
124 pub subscribe_completion: bool,
125 /// `subscribe_signal_delivery`.
126 pub subscribe_signal_delivery: bool,
127 /// `subscribe_instance_tags`. Deferred per #311 (cairn's `instance_tag_backfill`
128 /// pattern is served by `list_executions` + `ScannerFilter::with_instance_tag(..)`);
129 /// reported `false` on both backends today.
130 pub subscribe_instance_tags: bool,
131
132 // ── Streaming (RFC-015) ──
133 /// `read_summary` + durable-summary frames.
134 pub stream_durable_summary: bool,
135 /// `tail_stream` (best-effort live tail).
136 pub stream_best_effort_live: bool,
137 // Add new fields here, preserving parity-matrix order.
138}
139
140impl Supports {
141 /// Construct a `Supports` with every field `false`. Useful as a
142 /// starting point when assembling a backend-specific capability
143 /// snapshot. Consumers should never see this directly —
144 /// `capabilities()` on a real backend always returns a populated
145 /// instance.
146 pub const fn none() -> Self {
147 Self {
148 cancel_execution: false,
149 change_priority: false,
150 replay_execution: false,
151 revoke_lease: false,
152 read_execution_state: false,
153 read_execution_info: false,
154 get_execution_result: false,
155 budget_admin: false,
156 quota_admin: false,
157 rotate_waitpoint_hmac_secret_all: false,
158 seed_waitpoint_hmac_secret: false,
159 list_pending_waitpoints: false,
160 cancel_flow_header: false,
161 cancel_flow_wait_timeout: false,
162 cancel_flow_wait_indefinite: false,
163 ack_cancel_member: false,
164 claim_for_worker: false,
165 prepare: false,
166 subscribe_lease_history: false,
167 subscribe_completion: false,
168 subscribe_signal_delivery: false,
169 subscribe_instance_tags: false,
170 stream_durable_summary: false,
171 stream_best_effort_live: false,
172 }
173 }
174}
175
176/// Backend crate version. Kept as a struct (not a semver string) per
177/// RFC-018 §9 Q2: consumers can write
178/// `if backend.capabilities().identity.version >= Version::new(0, 10, 0) { .. }`
179/// without pulling a semver-parsing dep.
180#[non_exhaustive]
181#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
182pub struct Version {
183 /// Major version number.
184 pub major: u32,
185 /// Minor version number.
186 pub minor: u32,
187 /// Patch version number.
188 pub patch: u32,
189}
190
191impl Version {
192 /// Const constructor so concrete backends can declare a `const`
193 /// [`BackendIdentity`] without a function-call overhead in
194 /// `capabilities()`.
195 pub const fn new(major: u32, minor: u32, patch: u32) -> Self {
196 Self {
197 major,
198 minor,
199 patch,
200 }
201 }
202}
203
204/// Minimal-identity triple for a backend. Consumers that only need
205/// the family label + version (e.g. for metrics dimensioning) read
206/// this rather than the full [`Capabilities`].
207///
208/// `#[non_exhaustive]`: future stages may add fields (e.g. a
209/// backend-assigned `instance_id` or a `deployment_topology`
210/// hint); construct via [`Self::new`] or by returning one from
211/// [`crate::engine_backend::EngineBackend::capabilities`].
212#[non_exhaustive]
213#[derive(Clone, Copy, Debug, Eq, PartialEq)]
214pub struct BackendIdentity {
215 /// Stable backend family name. `"valkey"`, `"postgres"`, or a
216 /// concrete string set by an out-of-tree backend. `"unknown"`
217 /// is the pre-RFC-018 default.
218 pub family: &'static str,
219 /// Backend crate version at build time.
220 pub version: Version,
221 /// RFC-017 migration stage this backend reports itself certified
222 /// at. One of `"A"`, `"B"`, `"C"`, `"D"`, `"E"`, `"E-shipped"`,
223 /// or `"unknown"` for backends that predate the RFC-017 staging.
224 pub rfc017_stage: &'static str,
225}
226
227impl BackendIdentity {
228 /// Direct-field constructor. Prefer this over struct-literal
229 /// syntax in consumer code: `#[non_exhaustive]` forbids literal
230 /// construction from outside the defining crate.
231 pub const fn new(
232 family: &'static str,
233 version: Version,
234 rfc017_stage: &'static str,
235 ) -> Self {
236 Self {
237 family,
238 version,
239 rfc017_stage,
240 }
241 }
242}
243
244/// Full capability snapshot for a backend: its [`BackendIdentity`]
245/// plus a flat [`Supports`] surface of per-method bools.
246///
247/// Consumers typically read `caps.supports.<field>` to gate a UI
248/// surface or choose between two code paths before dispatching; the
249/// [`Self::identity`] side exists for metrics dimensioning + UI
250/// labelling.
251#[non_exhaustive]
252#[derive(Clone, Copy, Debug, Eq, PartialEq)]
253pub struct Capabilities {
254 /// Backend identity tuple this snapshot was assembled for.
255 pub identity: BackendIdentity,
256 /// Per-capability support bools.
257 pub supports: Supports,
258}
259
260impl Capabilities {
261 /// Construct a `Capabilities` value from an identity + a populated
262 /// [`Supports`]. Backends typically build one in `capabilities()`
263 /// without going through a constructor; this exists for
264 /// out-of-tree backends that prefer the explicit call.
265 pub const fn new(identity: BackendIdentity, supports: Supports) -> Self {
266 Self { identity, supports }
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 supports_none_is_all_false() {
295 let s = Supports::none();
296 // Spot-check a handful across the grouping policy; `Default`
297 // covers the exhaustive guarantee via `assert_eq!` below.
298 assert!(!s.cancel_execution);
299 assert!(!s.budget_admin);
300 assert!(!s.quota_admin);
301 assert!(!s.subscribe_instance_tags);
302 assert!(!s.stream_durable_summary);
303 // `Default::default()` must match `none()` so consumers that
304 // lean on `..Default::default()` get the same zero state.
305 assert_eq!(s, Supports::default());
306 }
307
308 #[test]
309 fn capabilities_new_wires_identity_and_supports() {
310 let mut s = Supports::none();
311 s.cancel_execution = true;
312 let caps = Capabilities::new(
313 BackendIdentity::new("valkey", Version::new(0, 10, 0), "E-shipped"),
314 s,
315 );
316 assert_eq!(caps.identity.family, "valkey");
317 assert!(caps.supports.cancel_execution);
318 assert!(!caps.supports.change_priority);
319 }
320}