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 [`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    // ── Reclaim (RFC-024) ──
111    /// `issue_reclaim_grant` + `reclaim_execution` (rolled up — both
112    /// land together on every in-tree backend per RFC-024 §3.6, so
113    /// one bool covers the consumer-visible reclaim surface).
114    /// `false` on out-of-tree backends via `Supports::none()`; `true`
115    /// on Valkey (v0.11.0 per RFC-024 PR-F), Postgres (PR-D), SQLite
116    /// (PR-E).
117    pub issue_reclaim_grant: bool,
118
119    // ── Boot ──
120    /// `prepare` does non-trivial work (e.g. Valkey `FUNCTION LOAD`).
121    /// Postgres reports `false` — `prepare` returns `NoOp` there.
122    pub prepare: bool,
123
124    // ── Stream subscriptions (RFC-019) ──
125    /// `subscribe_lease_history`.
126    pub subscribe_lease_history: bool,
127    /// `subscribe_completion`. On Valkey this is pubsub-backed
128    /// (non-durable cursor, at-most-once over the live subscription
129    /// window); Postgres is durable via outbox + cursor. Both report
130    /// `true`; see the trait method rustdoc for the non-durable-cursor
131    /// caveat and `docs/POSTGRES_PARITY_MATRIX.md` for the per-backend
132    /// semantic.
133    pub subscribe_completion: bool,
134    /// `subscribe_signal_delivery`.
135    pub subscribe_signal_delivery: bool,
136    /// `subscribe_instance_tags`. Deferred per #311 (cairn's `instance_tag_backfill`
137    /// pattern is served by `list_executions` + `ScannerFilter::with_instance_tag(..)`);
138    /// reported `false` on both backends today.
139    pub subscribe_instance_tags: bool,
140
141    // ── Streaming (RFC-015) ──
142    /// `read_summary` + durable-summary frames.
143    pub stream_durable_summary: bool,
144    /// `tail_stream` (best-effort live tail).
145    pub stream_best_effort_live: bool,
146
147    // ── Worker registry (RFC-025) ──
148    /// `register_worker`.
149    pub register_worker: bool,
150    /// `heartbeat_worker`.
151    pub heartbeat_worker: bool,
152    /// `mark_worker_dead`.
153    pub mark_worker_dead: bool,
154    /// `list_expired_leases`.
155    pub list_expired_leases: bool,
156    /// `list_workers` (RFC-025 Phase 6, §9.4).
157    pub list_workers: bool,
158
159    // ── Scheduler (FF #511) ──
160    /// `release_admission` — idempotent quota-admission slot release
161    /// used by `ff_scheduler` on the claim-fail rollback path. `true`
162    /// on every in-tree backend post-FF-#511.
163    pub release_admission: bool,
164    /// `read_quota_policy_limits` — typed snapshot of the admission
165    /// fields on a quota policy (FF #511 Phase 2a). Replaces the
166    /// Valkey-shaped 4-HGET pattern.
167    pub read_quota_policy_limits: bool,
168    /// `block_execution_for_admission` — generalised admission block
169    /// covering budget / quota / capability reason codes
170    /// (FF #511 Phase 2b).
171    pub block_execution_for_admission: bool,
172    /// `read_budget_usage_and_limits` — typed snapshot of a budget's
173    /// usage + limits hashes (FF #511 Phase 3). Replaces the
174    /// scheduler's Valkey-shaped HGETALL/HGET pattern.
175    pub read_budget_usage_and_limits: bool,
176    // Add new fields here, preserving parity-matrix order.
177}
178
179impl Supports {
180    /// Construct a [`Supports`] with every field `false`. Alias for
181    /// [`Self::none`] — provided so external consumers have a
182    /// conventional `new` constructor against this `#[non_exhaustive]`
183    /// struct. Equivalent to [`Self::default`].
184    pub const fn new() -> Self {
185        Self::none()
186    }
187
188    /// Construct a `Supports` with every field `false`. Useful as a
189    /// starting point when assembling a backend-specific capability
190    /// snapshot. Consumers should never see this directly —
191    /// `capabilities()` on a real backend always returns a populated
192    /// instance.
193    pub const fn none() -> Self {
194        Self {
195            cancel_execution: false,
196            change_priority: false,
197            replay_execution: false,
198            revoke_lease: false,
199            read_execution_state: false,
200            read_execution_info: false,
201            get_execution_result: false,
202            budget_admin: false,
203            quota_admin: false,
204            rotate_waitpoint_hmac_secret_all: false,
205            seed_waitpoint_hmac_secret: false,
206            list_pending_waitpoints: false,
207            cancel_flow_header: false,
208            cancel_flow_wait_timeout: false,
209            cancel_flow_wait_indefinite: false,
210            ack_cancel_member: false,
211            claim_for_worker: false,
212            issue_reclaim_grant: false,
213            prepare: false,
214            subscribe_lease_history: false,
215            subscribe_completion: false,
216            subscribe_signal_delivery: false,
217            subscribe_instance_tags: false,
218            stream_durable_summary: false,
219            stream_best_effort_live: false,
220            register_worker: false,
221            heartbeat_worker: false,
222            mark_worker_dead: false,
223            list_expired_leases: false,
224            list_workers: false,
225            release_admission: false,
226            read_quota_policy_limits: false,
227            block_execution_for_admission: false,
228            read_budget_usage_and_limits: false,
229        }
230    }
231}
232
233/// Backend crate version. Kept as a struct (not a semver string) per
234/// RFC-018 §9 Q2: consumers can write
235/// `if backend.capabilities().identity.version >= Version::new(0, 10, 0) { .. }`
236/// without pulling a semver-parsing dep.
237#[non_exhaustive]
238#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
239pub struct Version {
240    /// Major version number.
241    pub major: u32,
242    /// Minor version number.
243    pub minor: u32,
244    /// Patch version number.
245    pub patch: u32,
246}
247
248impl Version {
249    /// Const constructor so concrete backends can declare a `const`
250    /// [`BackendIdentity`] without a function-call overhead in
251    /// `capabilities()`.
252    pub const fn new(major: u32, minor: u32, patch: u32) -> Self {
253        Self {
254            major,
255            minor,
256            patch,
257        }
258    }
259}
260
261/// Minimal-identity triple for a backend. Consumers that only need
262/// the family label + version (e.g. for metrics dimensioning) read
263/// this rather than the full [`Capabilities`].
264///
265/// `#[non_exhaustive]`: future stages may add fields (e.g. a
266/// backend-assigned `instance_id` or a `deployment_topology`
267/// hint); construct via [`Self::new`] or by returning one from
268/// [`crate::engine_backend::EngineBackend::capabilities`].
269#[non_exhaustive]
270#[derive(Clone, Copy, Debug, Eq, PartialEq)]
271pub struct BackendIdentity {
272    /// Stable backend family name. `"valkey"`, `"postgres"`, or a
273    /// concrete string set by an out-of-tree backend. `"unknown"`
274    /// is the pre-RFC-018 default.
275    pub family: &'static str,
276    /// Backend crate version at build time.
277    pub version: Version,
278    /// RFC-017 migration stage this backend reports itself certified
279    /// at. One of `"A"`, `"B"`, `"C"`, `"D"`, `"E"`, `"E-shipped"`,
280    /// or `"unknown"` for backends that predate the RFC-017 staging.
281    pub rfc017_stage: &'static str,
282}
283
284impl BackendIdentity {
285    /// Direct-field constructor. Prefer this over struct-literal
286    /// syntax in consumer code: `#[non_exhaustive]` forbids literal
287    /// construction from outside the defining crate.
288    pub const fn new(
289        family: &'static str,
290        version: Version,
291        rfc017_stage: &'static str,
292    ) -> Self {
293        Self {
294            family,
295            version,
296            rfc017_stage,
297        }
298    }
299}
300
301/// Full capability snapshot for a backend: its [`BackendIdentity`]
302/// plus a flat [`Supports`] surface of per-method bools.
303///
304/// Consumers typically read `caps.supports.<field>` to gate a UI
305/// surface or choose between two code paths before dispatching; the
306/// [`Self::identity`] side exists for metrics dimensioning + UI
307/// labelling.
308#[non_exhaustive]
309#[derive(Clone, Copy, Debug, Eq, PartialEq)]
310pub struct Capabilities {
311    /// Backend identity tuple this snapshot was assembled for.
312    pub identity: BackendIdentity,
313    /// Per-capability support bools.
314    pub supports: Supports,
315}
316
317impl Capabilities {
318    /// Construct a `Capabilities` value from an identity + a populated
319    /// [`Supports`]. Backends typically build one in `capabilities()`
320    /// without going through a constructor; this exists for
321    /// out-of-tree backends that prefer the explicit call.
322    pub const fn new(identity: BackendIdentity, supports: Supports) -> Self {
323        Self { identity, supports }
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn version_new_is_const_and_ordered() {
333        const V: Version = Version::new(0, 9, 0);
334        assert_eq!(V.major, 0);
335        assert_eq!(V.minor, 9);
336        assert_eq!(V.patch, 0);
337        assert!(Version::new(0, 10, 0) > V);
338        assert!(Version::new(0, 9, 1) > V);
339        assert!(Version::new(1, 0, 0) > V);
340    }
341
342    #[test]
343    fn backend_identity_new_populates_fields() {
344        let id = BackendIdentity::new("valkey", Version::new(0, 9, 0), "E-shipped");
345        assert_eq!(id.family, "valkey");
346        assert_eq!(id.version, Version::new(0, 9, 0));
347        assert_eq!(id.rfc017_stage, "E-shipped");
348    }
349
350    #[test]
351    fn supports_none_is_all_false() {
352        let s = Supports::none();
353        // Spot-check a handful across the grouping policy; `Default`
354        // covers the exhaustive guarantee via `assert_eq!` below.
355        assert!(!s.cancel_execution);
356        assert!(!s.budget_admin);
357        assert!(!s.quota_admin);
358        assert!(!s.subscribe_instance_tags);
359        assert!(!s.stream_durable_summary);
360        // `Default::default()` must match `none()` so consumers that
361        // lean on `..Default::default()` get the same zero state.
362        assert_eq!(s, Supports::default());
363    }
364
365    #[test]
366    fn capabilities_new_wires_identity_and_supports() {
367        let mut s = Supports::none();
368        s.cancel_execution = true;
369        let caps = Capabilities::new(
370            BackendIdentity::new("valkey", Version::new(0, 10, 0), "E-shipped"),
371            s,
372        );
373        assert_eq!(caps.identity.family, "valkey");
374        assert!(caps.supports.cancel_execution);
375        assert!(!caps.supports.change_priority);
376    }
377}