Skip to main content

ff_core/
backend.rs

1//! Backend-trait supporting types (RFC-012 Stage 0).
2//!
3//! This module carries the public types referenced by the `EngineBackend`
4//! trait signatures in RFC-012 §3.3. The trait itself lands in Stage 1
5//! (issue #89 follow-up); Stage 0 is strictly type-plumbing and the
6//! `ResumeSignal` crate move.
7//!
8//! Public structs/enums whose fields or variants are expected to grow
9//! are marked `#[non_exhaustive]` per project convention — consumers
10//! must write `_`-terminated matches and use the provided constructors
11//! rather than struct literals. Exceptions:
12//!
13//! * Opaque single-field wrapper newtypes ([`HandleOpaque`],
14//!   [`WaitpointHmac`]) hide their inner field and need no non-exhaustive
15//!   annotation — the wrapped value is unreachable from outside.
16//! * [`ResumeSignal`] is intentionally NOT `#[non_exhaustive]` so the
17//!   ff-sdk crate-move (Stage 0) preserves struct-literal compatibility
18//!   at its existing call site.
19//!
20//! See `rfcs/RFC-012-engine-backend-trait.md` §3.3.0 for the authoritative
21//! type inventory and §4.1-§4.2 for the `Handle` / `EngineError` shapes.
22
23use crate::contracts::ReclaimGrant;
24use crate::types::{TimestampMs, WaitpointToken};
25
26// DX (HHH v0.3.4 re-smoke): `Namespace` lives in `ff_core::types` but
27// is used on `BackendConfig` + `ScannerFilter` (both defined in this
28// module). Re-export here so consumers already scoped to
29// `ff_core::backend::*` can grab it without a second `use` line
30// crossing into `ff_core::types`. Also brings `Namespace` into local
31// scope for the definitions below.
32pub use crate::types::Namespace;
33use std::collections::BTreeMap;
34use std::time::Duration;
35
36// ── §4.1 Handle trio ────────────────────────────────────────────────────
37
38/// Backend-tag discriminator embedded in every [`Handle`] so ops can
39/// dispatch to the correct backend implementation at runtime.
40///
41/// `#[non_exhaustive]`: new backend variants land additively as impls
42/// come online (e.g. `Postgres` in Stage 2, hypothetical third backends
43/// later).
44#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
45#[non_exhaustive]
46pub enum BackendTag {
47    /// The Valkey FCALL-backed implementation.
48    Valkey,
49}
50
51/// Lifecycle kind carried inside a [`Handle`]. Backends validate `kind`
52/// on entry to each op and return `EngineError::State` on mismatch.
53///
54/// Replaces round-1's compile-time `Handle` / `ResumeHandle` /
55/// `SuspendToken` type split (RFC-012 §4.1).
56#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
57#[non_exhaustive]
58pub enum HandleKind {
59    /// Fresh claim — returned by `claim` / `claim_from_grant`.
60    Fresh,
61    /// Resumed from reclaim — returned by `claim_from_reclaim`.
62    Resumed,
63    /// Suspended — returned by `suspend`. Terminal for the lease;
64    /// resumption mints a new Handle via `claim_from_reclaim`.
65    Suspended,
66}
67
68/// Backend-private opaque payload carried inside a [`Handle`].
69///
70/// Encodes backend-specific state (on Valkey: exec id, attempt id,
71/// lease id, lease epoch, capability binding, partition). Consumers do
72/// not construct or inspect the bytes — they are produced by the
73/// backend on claim/resume and consumed by the backend on each op.
74///
75/// `Box<[u8]>` chosen over `bytes::Bytes` (RFC-012 §7.17) to avoid a
76/// public-type transitive dep on the `bytes` crate.
77#[derive(Clone, Debug, PartialEq, Eq, Hash)]
78pub struct HandleOpaque(Box<[u8]>);
79
80impl HandleOpaque {
81    /// Construct from backend-owned bytes. Only backend impls call this.
82    pub fn new(bytes: Box<[u8]>) -> Self {
83        Self(bytes)
84    }
85
86    /// Borrow the underlying bytes (backend-internal use).
87    pub fn as_bytes(&self) -> &[u8] {
88        &self.0
89    }
90}
91
92/// Opaque attempt cookie held by the worker for the duration of an
93/// attempt. Produced by `claim` / `claim_from_reclaim` / `suspend`;
94/// borrowed by every op (renew, progress, append_frame, complete, fail,
95/// cancel, suspend, delay, wait_children, observe_signals, report_usage).
96///
97/// See RFC-012 §4.1 for the round-4 design — terminal ops borrow rather
98/// than consume so callers can retry after a transport error.
99#[derive(Clone, Debug, PartialEq, Eq, Hash)]
100#[non_exhaustive]
101pub struct Handle {
102    pub backend: BackendTag,
103    pub kind: HandleKind,
104    pub opaque: HandleOpaque,
105}
106
107impl Handle {
108    /// Construct a new Handle. Called by backend impls only; consumer
109    /// code receives Handles from `claim` / `suspend` / `claim_from_reclaim`.
110    pub fn new(backend: BackendTag, kind: HandleKind, opaque: HandleOpaque) -> Self {
111        Self {
112            backend,
113            kind,
114            opaque,
115        }
116    }
117}
118
119// ── §3.3.0 Claim / lifecycle supporting types ──────────────────────────
120
121/// Worker capability set — the tokens the worker advertises to the
122/// scheduler and to `claim`. Today stored as `Vec<String>` on
123/// `WorkerConfig`; promoted to a named newtype so the trait signatures
124/// can talk about capabilities without committing to a concrete
125/// container shape.
126///
127/// Bitfield vs stringly-typed is §7.2 open question; Stage 0 keeps the
128/// round-2 lean (newtype over `Vec<String>`).
129#[derive(Clone, Debug, PartialEq, Eq, Default)]
130#[non_exhaustive]
131pub struct CapabilitySet {
132    pub tokens: Vec<String>,
133}
134
135impl CapabilitySet {
136    /// Build from any iterable of string-like capability tokens.
137    pub fn new<I, S>(tokens: I) -> Self
138    where
139        I: IntoIterator<Item = S>,
140        S: Into<String>,
141    {
142        Self {
143            tokens: tokens.into_iter().map(Into::into).collect(),
144        }
145    }
146
147    /// True iff the set contains no tokens.
148    pub fn is_empty(&self) -> bool {
149        self.tokens.is_empty()
150    }
151}
152
153/// Policy hints for `claim`. Minimal at Stage 0 per RFC-012 §3.3.0
154/// ("Bikeshed-prone; keep minimal at Stage 0"). Future fields (retry
155/// count, fairness hints) land additively.
156#[derive(Clone, Debug, PartialEq, Eq, Default)]
157#[non_exhaustive]
158pub struct ClaimPolicy {
159    /// Maximum blocking wait. `None` means backend-default (today:
160    /// non-blocking / immediate return).
161    pub max_wait: Option<Duration>,
162}
163
164impl ClaimPolicy {
165    /// Zero-timeout claim (non-blocking). Matches today's SDK default.
166    pub fn immediate() -> Self {
167        Self { max_wait: None }
168    }
169
170    /// Claim with an explicit blocking bound.
171    pub fn with_max_wait(max_wait: Duration) -> Self {
172        Self {
173            max_wait: Some(max_wait),
174        }
175    }
176}
177
178/// Frame classification for `append_frame`. Mirrors the Lua-side
179/// `ff_append_frame` `frame_type` ARGV.
180#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
181#[non_exhaustive]
182pub enum FrameKind {
183    /// Operator-visible progress output / log line.
184    Stdout,
185    /// Operator-visible error / warning output.
186    Stderr,
187    /// Structured event (JSON payload).
188    Event,
189    /// Binary / opaque payload.
190    Blob,
191}
192
193/// Single stream frame appended via `append_frame` (RFC-012 §3.3.0).
194/// Today's FCALL takes the byte payload + frame_type + optional seq as
195/// discrete ARGV; Stage 0 collects them into a named type for trait
196/// signatures.
197///
198/// **Round-7 follow-up (PR #145 → #146):** extended with
199/// `frame_type: String` (the SDK-public free-form classifier — values
200/// like `"delta"`, `"log"`, `"agent_step"`, `"summary_token"`,
201/// `"transcribe_line"`, `"progress"` — distinct from the coarse
202/// [`FrameKind`] enum) and `correlation_id: Option<String>` (the
203/// wire-level `correlation_id` ARGV, surfaced at the SDK as
204/// `metadata: Option<&str>`). Adding these lets
205/// `ClaimedTask::append_frame` forward through the trait without
206/// wire-parity regression.
207///
208/// `frame_type` is free-form and is what the backend writes into the
209/// Lua-side `frame_type` ARGV. [`FrameKind`] remains for typed
210/// classification at the trait surface; when callers populate only
211/// `kind`, the backend falls back to a stable encoding of the enum
212/// variant (see `frame_kind_to_str` in `ff-backend-valkey`).
213#[derive(Clone, Debug, PartialEq, Eq)]
214#[non_exhaustive]
215pub struct Frame {
216    pub bytes: Vec<u8>,
217    pub kind: FrameKind,
218    /// Optional monotonic sequence. Set by the caller when the stream
219    /// protocol is sequence-bound; `None` lets the backend assign.
220    pub seq: Option<u64>,
221    /// Free-form classifier written to the Lua-side `frame_type` ARGV.
222    /// Empty string means "defer to [`FrameKind`]" — the backend
223    /// substitutes the enum-variant encoding.
224    pub frame_type: String,
225    /// Optional correlation id (wire `correlation_id` ARGV). `None`
226    /// encodes as the empty string on the wire.
227    pub correlation_id: Option<String>,
228}
229
230impl Frame {
231    /// Construct a frame. `seq` defaults to `None` (backend-assigned);
232    /// `frame_type` defaults to empty (backend falls back to
233    /// `FrameKind` encoding); `correlation_id` defaults to `None`.
234    /// Callers that need an explicit sequence use [`Frame::with_seq`];
235    /// callers on the SDK forwarder path populate `frame_type` +
236    /// `correlation_id` via [`Frame::with_frame_type`] /
237    /// [`Frame::with_correlation_id`].
238    pub fn new(bytes: Vec<u8>, kind: FrameKind) -> Self {
239        Self {
240            bytes,
241            kind,
242            seq: None,
243            frame_type: String::new(),
244            correlation_id: None,
245        }
246    }
247
248    /// Construct a frame with an explicit monotonic sequence.
249    pub fn with_seq(bytes: Vec<u8>, kind: FrameKind, seq: u64) -> Self {
250        Self {
251            bytes,
252            kind,
253            seq: Some(seq),
254            frame_type: String::new(),
255            correlation_id: None,
256        }
257    }
258
259    /// Builder-style setter for the free-form `frame_type` classifier.
260    pub fn with_frame_type(mut self, frame_type: impl Into<String>) -> Self {
261        self.frame_type = frame_type.into();
262        self
263    }
264
265    /// Builder-style setter for the optional `correlation_id`.
266    pub fn with_correlation_id(mut self, correlation_id: impl Into<String>) -> Self {
267        self.correlation_id = Some(correlation_id.into());
268        self
269    }
270}
271
272// ── §3.3.0 Suspend / waitpoint types ────────────────────────────────────
273
274/// Waitpoint matcher mode (mirrors today's suspend/close matcher kinds
275/// — signal name, correlation id, etc.).
276#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
277#[non_exhaustive]
278pub enum WaitpointKind {
279    /// Match by signal name.
280    SignalName,
281    /// Match by correlation id.
282    CorrelationId,
283    /// Generic external-signal waitpoint (external delivery).
284    External,
285}
286
287/// HMAC token that binds a waitpoint to its mint-time identity. Wire
288/// shape `kid:40hex`.
289///
290/// Wraps [`crate::types::WaitpointToken`] so bearer-credential Debug /
291/// Display redaction (`WaitpointToken("kid1:<REDACTED:len=40>")`) flows
292/// through automatically — no derived formatter can leak the raw
293/// digest when a [`WaitpointSpec`] is debug-printed via
294/// `tracing::debug!(spec=?spec)`.
295///
296/// Newtype-wrapping (vs. a `pub use` alias) keeps trait signatures
297/// naming the waitpoint-bound HMAC role explicitly.
298#[derive(Clone, PartialEq, Eq, Hash)]
299pub struct WaitpointHmac(WaitpointToken);
300
301impl WaitpointHmac {
302    pub fn new(token: impl Into<String>) -> Self {
303        Self(WaitpointToken::from(token.into()))
304    }
305
306    /// Borrow the wrapped token. The wrapped type's `Debug`/`Display`
307    /// redact — call sites that need the raw digest must explicitly
308    /// call [`WaitpointToken::as_str`].
309    pub fn token(&self) -> &WaitpointToken {
310        &self.0
311    }
312
313    /// Borrow the raw `kid:40hex` string. Prefer [`Self::token`] for
314    /// non-redacted call sites; this method exists only for transport
315    /// / FCALL ARGV construction where the raw wire bytes are required.
316    pub fn as_str(&self) -> &str {
317        self.0.as_str()
318    }
319}
320
321// Forward Debug / Display to the wrapped WaitpointToken so the
322// redaction guarantees on that type extend here. Derived Debug would
323// expose the raw string field and defeat the wrap.
324impl std::fmt::Debug for WaitpointHmac {
325    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
326        write!(f, "WaitpointHmac({:?})", self.0)
327    }
328}
329
330/// Handle returned by `create_waitpoint` — the id of the newly-minted
331/// pending waitpoint plus its HMAC token. Signals targeted at the
332/// waitpoint must present the token; a later `suspend` call transitions
333/// the waitpoint from `pending` to `active` (RFC-012 §R7.2.2).
334///
335/// `WaitpointHmac` redacts on `Debug`/`Display`, so deriving `Debug`
336/// here cannot leak the raw digest.
337#[derive(Clone, Debug, PartialEq, Eq)]
338#[non_exhaustive]
339pub struct PendingWaitpoint {
340    pub waitpoint_id: crate::types::WaitpointId,
341    pub hmac_token: WaitpointHmac,
342}
343
344impl PendingWaitpoint {
345    pub fn new(waitpoint_id: crate::types::WaitpointId, hmac_token: WaitpointHmac) -> Self {
346        Self {
347            waitpoint_id,
348            hmac_token,
349        }
350    }
351}
352
353/// One waitpoint inside a suspend request. `suspend` takes a
354/// `Vec<WaitpointSpec>`; the resume condition (`any` / `all`) lives on
355/// the enclosing suspend args in the Phase-1 contract.
356#[derive(Clone, Debug, PartialEq, Eq)]
357#[non_exhaustive]
358pub struct WaitpointSpec {
359    pub kind: WaitpointKind,
360    pub matcher: Vec<u8>,
361    pub hmac_token: WaitpointHmac,
362}
363
364impl WaitpointSpec {
365    pub fn new(kind: WaitpointKind, matcher: Vec<u8>, hmac_token: WaitpointHmac) -> Self {
366        Self {
367            kind,
368            matcher,
369            hmac_token,
370        }
371    }
372}
373
374// ── §3.3.0 Failure classification types ─────────────────────────────────
375
376/// Human-readable failure description + optional structured detail.
377/// Replaces today's ad-hoc string arg to `fail`.
378#[derive(Clone, Debug, PartialEq, Eq)]
379#[non_exhaustive]
380pub struct FailureReason {
381    pub message: String,
382    pub detail: Option<Vec<u8>>,
383}
384
385impl FailureReason {
386    pub fn new(message: impl Into<String>) -> Self {
387        Self {
388            message: message.into(),
389            detail: None,
390        }
391    }
392
393    pub fn with_detail(message: impl Into<String>, detail: Vec<u8>) -> Self {
394        Self {
395            message: message.into(),
396            detail: Some(detail),
397        }
398    }
399}
400
401/// Failure classification — determines retry disposition on the Lua
402/// side. Mirrors the Lua-side classification codes.
403#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
404#[non_exhaustive]
405pub enum FailureClass {
406    /// Retryable transient error.
407    Transient,
408    /// Permanent error — no retries.
409    Permanent,
410    /// Crash / process death inferred from lease expiry.
411    InfraCrash,
412    /// Timeout at the attempt or operation level.
413    Timeout,
414    /// Cooperative cancellation by operator or cancel_flow.
415    Cancelled,
416}
417
418// ── §3.3.0 Usage / budget types ─────────────────────────────────────────
419
420/// Usage report for `report_usage`. Mirrors today's
421/// `ff_report_usage_and_check` ARGV: token-counts, wall-time, custom
422/// dimensions.
423#[derive(Clone, Debug, PartialEq, Eq, Default)]
424#[non_exhaustive]
425pub struct UsageDimensions {
426    /// Input tokens consumed (LLM-shaped usage). `0` if not applicable.
427    pub input_tokens: u64,
428    /// Output tokens produced (LLM-shaped usage).
429    pub output_tokens: u64,
430    /// Wall-clock duration, in milliseconds, attributable to this
431    /// report. `None` for pure token-count reports.
432    pub wall_ms: Option<u64>,
433    /// Arbitrary caller-defined dimensions. Use `BTreeMap` for stable
434    /// iteration order (important for dedup-key derivation on some
435    /// budget schemes).
436    pub custom: BTreeMap<String, u64>,
437    /// Optional caller-supplied idempotency key. When set, the backend
438    /// rejects a repeat application of the same key with
439    /// `ReportUsageResult::AlreadyApplied` rather than double-counting
440    /// (RFC-012 §R7.4; Lua `ff_report_usage_and_check` threads this as
441    /// the trailing ARGV). `None` / empty string disables dedup.
442    pub dedup_key: Option<String>,
443}
444
445impl UsageDimensions {
446    /// Create an empty usage report (all dimensions zero / `None`).
447    ///
448    /// Provided for external-crate consumers: `UsageDimensions` is
449    /// `#[non_exhaustive]`, so struct-literal and functional-update
450    /// construction are unavailable across crate boundaries. Start
451    /// from `new()` and chain `with_*` setters to build a report.
452    pub fn new() -> Self {
453        Self::default()
454    }
455
456    /// Set the input-token count dimension. Consumes and returns
457    /// `self` for chaining.
458    pub fn with_input_tokens(mut self, tokens: u64) -> Self {
459        self.input_tokens = tokens;
460        self
461    }
462
463    /// Set the output-token count dimension. Consumes and returns
464    /// `self` for chaining.
465    pub fn with_output_tokens(mut self, tokens: u64) -> Self {
466        self.output_tokens = tokens;
467        self
468    }
469
470    /// Set the wall-clock duration dimension, in milliseconds.
471    /// Consumes and returns `self` for chaining.
472    pub fn with_wall_ms(mut self, ms: u64) -> Self {
473        self.wall_ms = Some(ms);
474        self
475    }
476
477    /// Set the optional caller-supplied idempotency key. When set,
478    /// the backend rejects a repeat application of the same key with
479    /// `ReportUsageResult::AlreadyApplied` rather than double-counting
480    /// (RFC-012 §R7.4). Consumes and returns `self` for chaining.
481    pub fn with_dedup_key(mut self, key: impl Into<String>) -> Self {
482        self.dedup_key = Some(key.into());
483        self
484    }
485}
486
487// ── §3.3.0 Reclaim / lease types ────────────────────────────────────────
488
489/// Opaque cookie returned by the reclaim scanner; consumed by
490/// `claim_from_reclaim` to mint a resumed Handle.
491///
492/// Wraps [`ReclaimGrant`] today (the scanner's existing product).
493/// Kept as a newtype so trait signatures name the reclaim-bound role
494/// explicitly and so the wrapped shape can evolve without breaking the
495/// trait.
496#[derive(Clone, Debug, PartialEq, Eq)]
497#[non_exhaustive]
498pub struct ReclaimToken {
499    pub grant: ReclaimGrant,
500}
501
502impl ReclaimToken {
503    pub fn new(grant: ReclaimGrant) -> Self {
504        Self { grant }
505    }
506}
507
508/// Result of a successful `renew` call.
509#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
510#[non_exhaustive]
511pub struct LeaseRenewal {
512    /// New lease expiry (monotonic ms).
513    pub expires_at_ms: u64,
514    /// Lease epoch after renewal. Monotonic non-decreasing.
515    pub lease_epoch: u64,
516}
517
518impl LeaseRenewal {
519    pub fn new(expires_at_ms: u64, lease_epoch: u64) -> Self {
520        Self {
521            expires_at_ms,
522            lease_epoch,
523        }
524    }
525}
526
527// ── §3.3.0 Cancel-flow supporting types ─────────────────────────────────
528
529/// Cancel-flow policy — what to do with the flow's members. Today
530/// encoded as a `String` on `CancelFlowArgs`; Stage 0 extracts the
531/// policy shape as a typed enum (RFC-012 §3.3.0).
532#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
533#[non_exhaustive]
534pub enum CancelFlowPolicy {
535    /// Cancel only the flow record; leave members alone.
536    FlowOnly,
537    /// Cancel the flow and every non-terminal member execution.
538    CancelAll,
539    /// Cancel the flow and every member currently in `Pending` /
540    /// `Blocked` / `Eligible` — leave `Running` executions alone to
541    /// drain.
542    CancelPending,
543}
544
545/// Caller wait posture for `cancel_flow`.
546#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
547#[non_exhaustive]
548pub enum CancelFlowWait {
549    /// Return after the flow-state flip; member cancellations dispatch
550    /// asynchronously.
551    NoWait,
552    /// Block until member cancellations complete, up to `timeout`.
553    WaitTimeout(Duration),
554    /// Block until member cancellations complete, no deadline.
555    WaitIndefinite,
556}
557
558// ── §3.3.0 Completion stream payload ────────────────────────────────────
559
560/// One completion event delivered through the `CompletionStream`
561/// (RFC-012 §4.3). Also the payload type for issue #90's subscription
562/// API. Stage 0 authorises the type; issue #90 fixes the wire shape.
563///
564/// `flow_id` was added in issue #90 so DAG-dependency routing
565/// (`dispatch_dependency_resolution`) has the partition-routable flow
566/// handle without reparsing the Lua-emitted JSON downstream.
567/// `#[non_exhaustive]` keeps future field additions additive; use
568/// [`CompletionPayload::new`] and [`CompletionPayload::with_flow_id`]
569/// for construction.
570///
571/// `payload_bytes` / `produced_at_ms` are authorised but not yet
572/// populated by the Valkey Lua emitters — consumers on the
573/// `CompletionStream` read `execution_id` + `flow_id` today and must
574/// tolerate `payload_bytes = None` / `produced_at_ms = 0`.
575#[derive(Clone, Debug, PartialEq, Eq)]
576#[non_exhaustive]
577pub struct CompletionPayload {
578    pub execution_id: crate::types::ExecutionId,
579    pub outcome: String,
580    pub payload_bytes: Option<Vec<u8>>,
581    pub produced_at_ms: TimestampMs,
582    /// Flow handle for partition routing. Added in issue #90 (#90);
583    /// `None` for emitters that don't yet surface it.
584    pub flow_id: Option<crate::types::FlowId>,
585}
586
587impl CompletionPayload {
588    pub fn new(
589        execution_id: crate::types::ExecutionId,
590        outcome: impl Into<String>,
591        payload_bytes: Option<Vec<u8>>,
592        produced_at_ms: TimestampMs,
593    ) -> Self {
594        Self {
595            execution_id,
596            outcome: outcome.into(),
597            payload_bytes,
598            produced_at_ms,
599            flow_id: None,
600        }
601    }
602
603    /// Attach a flow handle to the payload. Additive builder so adding
604    /// `flow_id` didn't require a breaking change to [`Self::new`].
605    #[must_use]
606    pub fn with_flow_id(mut self, flow_id: crate::types::FlowId) -> Self {
607        self.flow_id = Some(flow_id);
608        self
609    }
610}
611
612// ── §3.3.0 ResumeSignal (crate move from ff-sdk::task) ──────────────────
613
614/// Signal that satisfied a waitpoint matcher and is therefore part of
615/// the reason an execution resumed. Returned by `observe_signals`
616/// (RFC-012 §3.1.2) and by `ClaimedTask::resume_signals` in ff-sdk.
617///
618/// Moved in Stage 0 from `ff_sdk::task`; `ff_sdk::ResumeSignal` remains
619/// re-exported through the 0.4.x window (removal scheduled for 0.5.0).
620///
621/// Returned only for signals whose matcher slot in the waitpoint's
622/// resume condition is marked satisfied. Pre-buffered-but-unmatched
623/// signals are not included.
624///
625/// Note: NOT `#[non_exhaustive]` to preserve struct-literal compatibility
626/// with ff-sdk call sites that constructed `ResumeSignal { .. }` before
627/// the Stage 0 crate move.
628#[derive(Clone, Debug, PartialEq, Eq)]
629pub struct ResumeSignal {
630    pub signal_id: crate::types::SignalId,
631    pub signal_name: String,
632    pub signal_category: String,
633    pub source_type: String,
634    pub source_identity: String,
635    pub correlation_id: String,
636    /// Valkey-server `now_ms` timestamp at which `ff_deliver_signal`
637    /// accepted this signal. `0` if the stored `accepted_at` field is
638    /// missing or non-numeric (a Lua-side defect — not expected at
639    /// runtime).
640    pub accepted_at: TimestampMs,
641    /// Raw payload bytes, if the signal was delivered with one. `None`
642    /// for signals delivered without a payload.
643    pub payload: Option<Vec<u8>>,
644}
645
646// ── Stage 1a: FailOutcome move ──────────────────────────────────────────
647
648/// Outcome of a `fail()` call.
649///
650/// **RFC-012 Stage 1a:** moved from `ff_sdk::task::FailOutcome` to
651/// `ff_core::backend::FailOutcome` so it is nameable by the
652/// `EngineBackend` trait signature. `ff_sdk::task` retains a
653/// `pub use` shim preserving the `ff_sdk::FailOutcome` path.
654///
655/// Not `#[non_exhaustive]` because existing consumers (ff-test,
656/// ff-readiness-tests) construct and match this enum exhaustively;
657/// the shape has been stable since the `fail()` API landed and any
658/// additive growth would be a follow-up RFC's deliberate break.
659#[derive(Clone, Debug, PartialEq, Eq)]
660pub enum FailOutcome {
661    /// Retry was scheduled — execution is in delayed backoff.
662    RetryScheduled {
663        delay_until: crate::types::TimestampMs,
664    },
665    /// No retries left — execution is terminal failed.
666    TerminalFailed,
667}
668
669// ── RFC-012 §R7: AppendFrameOutcome move ────────────────────────────────
670
671/// Outcome of an `append_frame()` call.
672///
673/// **RFC-012 §R7.2.1:** moved from `ff_sdk::task::AppendFrameOutcome`
674/// to `ff_core::backend::AppendFrameOutcome` so it is nameable by the
675/// `EngineBackend::append_frame` trait return. `ff_sdk::task` retains
676/// a `pub use` shim preserving the `ff_sdk::task::AppendFrameOutcome`
677/// path through 0.4.x.
678///
679/// Derive set matches the `FailOutcome` precedent
680/// (`Clone, Debug, PartialEq, Eq`). Not `#[non_exhaustive]`:
681/// construction is internal to the backend today (parser in
682/// `ff-backend-valkey`), and no external constructors are anticipated
683/// (consumer-shape evidence per §R7.2.1 / MN3).
684///
685/// `stream_id: String` is a stable shape commitment — a future typed
686/// `StreamId` newtype would be its own breaking change (§R7.5.6 / MD2).
687#[derive(Clone, Debug, PartialEq, Eq)]
688pub struct AppendFrameOutcome {
689    /// Valkey Stream entry ID assigned to this frame (e.g. `1234567890-0`).
690    pub stream_id: String,
691    /// Total frame count in the stream after this append.
692    pub frame_count: u64,
693}
694
695// ── Stage 1a: BackendConfig + sub-types ─────────────────────────────────
696
697/// Pool timing shared across backend connections.
698///
699/// Fields are `#[non_exhaustive]` per project convention — connection
700/// tunables grow as new backends land. Default is the Phase-1 Valkey
701/// client's out-of-box shape (no explicit timeout, no explicit pool
702/// cap; inherits ferriskey defaults).
703#[derive(Clone, Debug, Default, PartialEq, Eq)]
704#[non_exhaustive]
705pub struct BackendTimeouts {
706    /// Per-request timeout. `None` ⇒ backend default.
707    pub request: Option<Duration>,
708}
709
710/// Retry policy shared across backend connections.
711///
712/// Matches ferriskey's `ConnectionRetryStrategy` shape (see
713/// `ferriskey/src/client/types.rs:151`) so Stage 1c's Valkey wiring is a
714/// direct pass-through — we don't reimplement what ferriskey already
715/// provides. The Postgres backend (future) interprets the same fields
716/// under its own retry semantics, or maps `None` to its own defaults.
717///
718/// Each field is `Option<u32>`: `None` ⇒ backend default (for Valkey,
719/// this means `ConnectionRetryStrategy::default()`); `Some(v)` ⇒ pass
720/// `v` straight through.
721#[derive(Clone, Debug, Default, PartialEq, Eq)]
722#[non_exhaustive]
723pub struct BackendRetry {
724    /// Exponent base for the backoff curve. `None` ⇒ backend default.
725    pub exponent_base: Option<u32>,
726    /// Multiplicative factor applied to each backoff step. `None` ⇒
727    /// backend default.
728    pub factor: Option<u32>,
729    /// Maximum number of retry attempts on transient transport errors.
730    /// `None` ⇒ backend default.
731    pub number_of_retries: Option<u32>,
732    /// Jitter as a percentage of the computed backoff. `None` ⇒ backend
733    /// default.
734    pub jitter_percent: Option<u32>,
735}
736
737/// Valkey-specific connection parameters.
738#[derive(Clone, Debug, PartialEq, Eq)]
739#[non_exhaustive]
740pub struct ValkeyConnection {
741    /// Valkey hostname.
742    pub host: String,
743    /// Valkey port.
744    pub port: u16,
745    /// Enable TLS for the Valkey connection.
746    pub tls: bool,
747    /// Enable Valkey cluster mode.
748    pub cluster: bool,
749}
750
751impl ValkeyConnection {
752    pub fn new(host: impl Into<String>, port: u16) -> Self {
753        Self {
754            host: host.into(),
755            port,
756            tls: false,
757            cluster: false,
758        }
759    }
760}
761
762/// Discriminated union over per-backend connection shapes. Stage 1a
763/// ships the Valkey arm; future backends (Postgres) land additively
764/// under the `#[non_exhaustive]` guard.
765#[derive(Clone, Debug, PartialEq, Eq)]
766#[non_exhaustive]
767pub enum BackendConnection {
768    Valkey(ValkeyConnection),
769}
770
771/// Configuration passed to `ValkeyBackend::connect` (and, later, to
772/// other backend `connect` constructors). Carries the connection
773/// details + shared timing/retry policy. Replaces the Valkey-specific
774/// fields today on `WorkerConfig` (RFC-012 §5.1 migration plan).
775///
776/// `BackendConfig` is the replacement target for `WorkerConfig`'s
777/// `host` / `port` / `tls` / `cluster` fields. The full migration
778/// lands across Stage 1a (type introduction) and Stage 1c
779/// (`WorkerConfig` forwarding); worker-policy fields (lease TTL,
780/// claim poll interval, capability set) stay on `WorkerConfig`.
781#[derive(Clone, Debug, PartialEq, Eq)]
782#[non_exhaustive]
783pub struct BackendConfig {
784    pub connection: BackendConnection,
785    pub timeouts: BackendTimeouts,
786    pub retry: BackendRetry,
787}
788
789impl BackendConfig {
790    /// Build a Valkey BackendConfig from host+port. Other fields take
791    /// backend defaults.
792    pub fn valkey(host: impl Into<String>, port: u16) -> Self {
793        Self {
794            connection: BackendConnection::Valkey(ValkeyConnection::new(host, port)),
795            timeouts: BackendTimeouts::default(),
796            retry: BackendRetry::default(),
797        }
798    }
799}
800
801// ── Issue #122: ScannerFilter ───────────────────────────────────────────
802
803/// Per-consumer filter applied by FlowFabric's background scanners and
804/// completion subscribers so multiple FlowFabric instances sharing a
805/// single Valkey keyspace can operate on disjoint subsets of
806/// executions without mutual interference (issue #122).
807///
808/// Sibling of [`CompletionPayload`]: the former is a scan *output*
809/// shape, this is the *input* predicate scanners and completion
810/// subscribers consult per candidate.
811///
812/// # Fields & backing storage
813///
814/// * `namespace` — matches against the `namespace` field on
815///   `exec_core` (Valkey hash `ff:exec:{fp:N}:<eid>:core`). Cost:
816///   one HGET per candidate when set.
817/// * `instance_tag` — matches against an entry in the execution's
818///   user-supplied tags hash at the canonical key
819///   **`ff:exec:{p}:<eid>:tags`** (where `{p}` is the partition
820///   hash-tag, e.g. `{fp:42}`). Written by the Lua function
821///   `ff_create_execution` (see `lua/execution.lua`) and
822///   `ff_set_execution_tags`. The tuple is `(tag_key, tag_value)`:
823///   the HGET targets `tag_key` on the tags hash and compares the
824///   returned string (if any) byte-for-byte against `tag_value`.
825///   Cost: one HGET per candidate when set.
826///
827/// When both are set, scanners check `namespace` first (short-circuit
828/// on mismatch) then `instance_tag`, for a maximum of 2 HGETs per
829/// candidate.
830///
831/// # Semantics
832///
833/// * [`Self::is_noop`] returns true when both fields are `None` —
834///   the filter accepts every candidate. Used by the
835///   `subscribe_completions_filtered` default body to fall back to
836///   the unfiltered subscription.
837/// * [`Self::matches`] is the tight in-memory predicate once the
838///   HGET values have been fetched. Scanners fetch the fields
839///   lazily (namespace first) and pass the results in; the helper
840///   returns false as soon as one component mismatches.
841///
842/// # Scope
843///
844/// Today the filter is consulted by execution-shaped scanners
845/// (lease_expiry, attempt_timeout, execution_deadline,
846/// suspension_timeout, pending_wp_expiry, delayed_promoter,
847/// dependency_reconciler, cancel_reconciler, unblock,
848/// index_reconciler, retention_trimmer) and by completion
849/// subscribers. Non-execution scanners (budget_reconciler,
850/// budget_reset, quota_reconciler, flow_projector) accept a filter
851/// for API uniformity but do not apply it — their iteration domains
852/// (budgets, quotas, flows) are not keyed by the
853/// per-execution namespace / instance_tag shape.
854///
855/// `#[non_exhaustive]` so future dimensions (e.g. `lane_id`,
856/// `worker_instance`) can land additively.
857#[derive(Clone, Debug, Default, PartialEq, Eq)]
858#[non_exhaustive]
859pub struct ScannerFilter {
860    /// Tenant / workspace scope. Matches against the `namespace`
861    /// field on `exec_core`.
862    pub namespace: Option<Namespace>,
863    /// Instance-scoped tag predicate `(tag_key, tag_value)`. Matches
864    /// against an entry in `ff:exec:{p}:<eid>:tags` (the tags hash
865    /// written by `ff_create_execution`).
866    pub instance_tag: Option<(String, String)>,
867}
868
869impl ScannerFilter {
870    /// Shared no-op filter — useful as the default for the
871    /// `Scanner::filter()` trait method so implementors that don't
872    /// override can hand back a `&'static` reference without
873    /// allocating per call.
874    pub const NOOP: Self = Self {
875        namespace: None,
876        instance_tag: None,
877    };
878
879    /// Create an empty filter (equivalent to [`Self::NOOP`]).
880    ///
881    /// Provided for external-crate consumers: `ScannerFilter` is
882    /// `#[non_exhaustive]`, so struct-literal and functional-update
883    /// construction are unavailable across crate boundaries. Start
884    /// from `new()` and chain `with_*` setters to build a filter.
885    pub fn new() -> Self {
886        Self::default()
887    }
888
889    /// Set the tenant-scope namespace filter dimension. Consumes
890    /// and returns `self` for chaining.
891    ///
892    /// Accepts anything that converts into [`Namespace`] so callers
893    /// can pass `&str` / `String` directly without the
894    /// `Namespace::new(...)` ceremony (the conversion is infallible;
895    /// [`Namespace`] has `From<&str>` and `From<String>` via the
896    /// crate's `string_id!` macro).
897    pub fn with_namespace(mut self, ns: impl Into<Namespace>) -> Self {
898        self.namespace = Some(ns.into());
899        self
900    }
901
902    /// Set the exact-match exec-tag filter dimension. Consumes and
903    /// returns `self` for chaining. At filter time, scanners read
904    /// the `ff:exec:{p:N}:<eid>:tags` hash and compare the value at
905    /// `key` byte-for-byte against `value`.
906    pub fn with_instance_tag(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
907        self.instance_tag = Some((key.into(), value.into()));
908        self
909    }
910
911    /// True iff the filter has no dimensions set — every candidate
912    /// passes. Callers use this to short-circuit filtered subscribe
913    /// paths back to the unfiltered ones.
914    pub fn is_noop(&self) -> bool {
915        self.namespace.is_none() && self.instance_tag.is_none()
916    }
917
918    /// Post-HGET in-memory match check.
919    ///
920    /// `core_namespace` should be the `namespace` field read from
921    /// `exec_core` (None if the HGET returned nil / was skipped).
922    /// `tag_value` should be the HGET result for the configured
923    /// `instance_tag.0` key on the execution's tags hash (None if
924    /// the HGET returned nil / was skipped).
925    ///
926    /// When a filter dimension is `None` the corresponding argument
927    /// is ignored — callers may pass `None` to skip the HGET and
928    /// save a round-trip.
929    pub fn matches(
930        &self,
931        core_namespace: Option<&Namespace>,
932        tag_value: Option<&str>,
933    ) -> bool {
934        if let Some(ref want) = self.namespace {
935            match core_namespace {
936                Some(have) if have == want => {}
937                _ => return false,
938            }
939        }
940        if let Some((_, ref want_value)) = self.instance_tag {
941            match tag_value {
942                Some(have) if have == want_value.as_str() => {}
943                _ => return false,
944            }
945        }
946        true
947    }
948}
949
950// ── Tests ───────────────────────────────────────────────────────────────
951
952#[cfg(test)]
953mod tests {
954    use super::*;
955    use crate::partition::{Partition, PartitionFamily};
956    use crate::types::{ExecutionId, LaneId, SignalId};
957
958    #[test]
959    fn backend_tag_derives() {
960        let a = BackendTag::Valkey;
961        let b = a;
962        assert_eq!(a, b);
963        assert_eq!(format!("{a:?}"), "Valkey");
964    }
965
966    #[test]
967    fn handle_kind_derives() {
968        for k in [HandleKind::Fresh, HandleKind::Resumed, HandleKind::Suspended] {
969            let c = k;
970            assert_eq!(k, c);
971            // Debug formatter reachable
972            let _ = format!("{k:?}");
973        }
974    }
975
976    #[test]
977    fn handle_opaque_roundtrips() {
978        let bytes: Box<[u8]> = Box::new([1u8, 2, 3, 4]);
979        let o = HandleOpaque::new(bytes.clone());
980        assert_eq!(o.as_bytes(), &[1u8, 2, 3, 4]);
981        assert_eq!(o, o.clone());
982        let _ = format!("{o:?}");
983    }
984
985    #[test]
986    fn handle_composes() {
987        let h = Handle::new(
988            BackendTag::Valkey,
989            HandleKind::Fresh,
990            HandleOpaque::new(Box::new([0u8; 4])),
991        );
992        assert_eq!(h.backend, BackendTag::Valkey);
993        assert_eq!(h.kind, HandleKind::Fresh);
994        assert_eq!(h.clone(), h);
995    }
996
997    #[test]
998    fn capability_set_derives() {
999        let c1 = CapabilitySet::new(["gpu", "cuda"]);
1000        let c2 = CapabilitySet::new(["gpu", "cuda"]);
1001        assert_eq!(c1, c2);
1002        assert!(!c1.is_empty());
1003        assert!(CapabilitySet::default().is_empty());
1004        let _ = format!("{c1:?}");
1005    }
1006
1007    #[test]
1008    fn claim_policy_derives() {
1009        let p = ClaimPolicy::with_max_wait(Duration::from_millis(500));
1010        assert_eq!(p.max_wait, Some(Duration::from_millis(500)));
1011        assert_eq!(p.clone(), p);
1012        assert_eq!(ClaimPolicy::immediate(), ClaimPolicy::default());
1013    }
1014
1015    #[test]
1016    fn frame_and_kind_derive() {
1017        let f = Frame {
1018            bytes: b"hello".to_vec(),
1019            kind: FrameKind::Stdout,
1020            seq: Some(3),
1021            frame_type: "delta".to_owned(),
1022            correlation_id: Some("req-42".to_owned()),
1023        };
1024        assert_eq!(f.clone(), f);
1025        assert_eq!(f.kind, FrameKind::Stdout);
1026        assert_eq!(f.frame_type, "delta");
1027        assert_eq!(f.correlation_id.as_deref(), Some("req-42"));
1028        assert_ne!(FrameKind::Stderr, FrameKind::Event);
1029        let _ = format!("{f:?}");
1030    }
1031
1032    #[test]
1033    fn frame_builders_populate_extended_fields() {
1034        let f = Frame::new(b"payload".to_vec(), FrameKind::Event)
1035            .with_frame_type("agent_step")
1036            .with_correlation_id("corr-1");
1037        assert_eq!(f.frame_type, "agent_step");
1038        assert_eq!(f.correlation_id.as_deref(), Some("corr-1"));
1039        assert_eq!(f.seq, None);
1040
1041        let bare = Frame::new(b"p".to_vec(), FrameKind::Event);
1042        assert_eq!(bare.frame_type, "");
1043        assert_eq!(bare.correlation_id, None);
1044    }
1045
1046    #[test]
1047    fn waitpoint_spec_derives() {
1048        let spec = WaitpointSpec {
1049            kind: WaitpointKind::SignalName,
1050            matcher: b"approved".to_vec(),
1051            hmac_token: WaitpointHmac::new("kid1:deadbeef"),
1052        };
1053        assert_eq!(spec.clone(), spec);
1054        assert_eq!(spec.hmac_token.as_str(), "kid1:deadbeef");
1055        assert_eq!(
1056            WaitpointHmac::new("a"),
1057            WaitpointHmac::new(String::from("a"))
1058        );
1059    }
1060
1061    #[test]
1062    fn failure_reason_and_class() {
1063        let r1 = FailureReason::new("boom");
1064        let r2 = FailureReason::with_detail("boom", b"stack".to_vec());
1065        assert_eq!(r1.message, "boom");
1066        assert!(r1.detail.is_none());
1067        assert_eq!(r2.detail.as_deref(), Some(&b"stack"[..]));
1068        assert_eq!(r1.clone(), r1);
1069        assert_ne!(FailureClass::Transient, FailureClass::Permanent);
1070    }
1071
1072    #[test]
1073    fn usage_dimensions_default_and_eq() {
1074        let u = UsageDimensions {
1075            input_tokens: 10,
1076            output_tokens: 20,
1077            wall_ms: Some(150),
1078            custom: BTreeMap::from([("net_bytes".to_string(), 42)]),
1079            dedup_key: Some("k1".into()),
1080        };
1081        assert_eq!(u.clone(), u);
1082        assert_eq!(UsageDimensions::default().input_tokens, 0);
1083        assert_eq!(UsageDimensions::default().dedup_key, None);
1084    }
1085
1086    #[test]
1087    fn usage_dimensions_builder_chain() {
1088        let u = UsageDimensions::new()
1089            .with_input_tokens(10)
1090            .with_output_tokens(20)
1091            .with_wall_ms(150)
1092            .with_dedup_key("k1");
1093        assert_eq!(u.input_tokens, 10);
1094        assert_eq!(u.output_tokens, 20);
1095        assert_eq!(u.wall_ms, Some(150));
1096        assert_eq!(u.dedup_key.as_deref(), Some("k1"));
1097        assert!(u.custom.is_empty());
1098        // `new()` is equivalent to `default()`.
1099        assert_eq!(UsageDimensions::new(), UsageDimensions::default());
1100    }
1101
1102    #[test]
1103    fn reclaim_token_wraps_grant() {
1104        let grant = ReclaimGrant {
1105            execution_id: ExecutionId::solo(&LaneId::new("default"), &Default::default()),
1106            partition_key: crate::partition::PartitionKey::from(&Partition {
1107                family: PartitionFamily::Flow,
1108                index: 0,
1109            }),
1110            grant_key: "gkey".into(),
1111            expires_at_ms: 123,
1112            lane_id: LaneId::new("default"),
1113        };
1114        let t = ReclaimToken::new(grant.clone());
1115        assert_eq!(t.grant, grant);
1116        assert_eq!(t.clone(), t);
1117    }
1118
1119    #[test]
1120    fn lease_renewal_is_copy() {
1121        let r = LeaseRenewal {
1122            expires_at_ms: 100,
1123            lease_epoch: 2,
1124        };
1125        let s = r; // Copy
1126        assert_eq!(r, s);
1127    }
1128
1129    #[test]
1130    fn cancel_flow_policy_and_wait() {
1131        assert_ne!(CancelFlowPolicy::FlowOnly, CancelFlowPolicy::CancelAll);
1132        let w = CancelFlowWait::WaitTimeout(Duration::from_secs(1));
1133        assert_eq!(w, w);
1134        assert_ne!(CancelFlowWait::NoWait, CancelFlowWait::WaitIndefinite);
1135    }
1136
1137    #[test]
1138    fn completion_payload_derives() {
1139        let c = CompletionPayload {
1140            execution_id: ExecutionId::solo(&LaneId::new("default"), &Default::default()),
1141            outcome: "success".into(),
1142            payload_bytes: Some(b"ok".to_vec()),
1143            produced_at_ms: TimestampMs::from_millis(1234),
1144            flow_id: None,
1145        };
1146        assert_eq!(c.clone(), c);
1147        let _ = format!("{c:?}");
1148    }
1149
1150    #[test]
1151    fn resume_signal_moved_and_derives() {
1152        let s = ResumeSignal {
1153            signal_id: SignalId::new(),
1154            signal_name: "approve".into(),
1155            signal_category: "decision".into(),
1156            source_type: "user".into(),
1157            source_identity: "u1".into(),
1158            correlation_id: "c1".into(),
1159            accepted_at: TimestampMs::from_millis(10),
1160            payload: None,
1161        };
1162        assert_eq!(s.clone(), s);
1163        let _ = format!("{s:?}");
1164    }
1165
1166    #[test]
1167    fn fail_outcome_variants() {
1168        let retry = FailOutcome::RetryScheduled {
1169            delay_until: TimestampMs::from_millis(42),
1170        };
1171        let terminal = FailOutcome::TerminalFailed;
1172        assert_ne!(retry, terminal);
1173        assert_eq!(retry.clone(), retry);
1174    }
1175
1176    #[test]
1177    fn scanner_filter_noop_and_default() {
1178        let f = ScannerFilter::default();
1179        assert!(f.is_noop());
1180        assert_eq!(f, ScannerFilter::NOOP);
1181        // A no-op filter matches any candidate, including ones that
1182        // produced no HGET results.
1183        assert!(f.matches(None, None));
1184        assert!(f.matches(Some(&Namespace::new("t1")), Some("v")));
1185    }
1186
1187    #[test]
1188    fn scanner_filter_namespace_match() {
1189        let f = ScannerFilter {
1190            namespace: Some(Namespace::new("tenant-a")),
1191            instance_tag: None,
1192        };
1193        assert!(!f.is_noop());
1194        assert!(f.matches(Some(&Namespace::new("tenant-a")), None));
1195        assert!(!f.matches(Some(&Namespace::new("tenant-b")), None));
1196        // Missing core namespace ⇒ no match.
1197        assert!(!f.matches(None, None));
1198    }
1199
1200    #[test]
1201    fn scanner_filter_instance_tag_match() {
1202        let f = ScannerFilter {
1203            namespace: None,
1204            instance_tag: Some(("cairn.instance_id".into(), "i-1".into())),
1205        };
1206        assert!(f.matches(None, Some("i-1")));
1207        assert!(!f.matches(None, Some("i-2")));
1208        assert!(!f.matches(None, None));
1209    }
1210
1211    #[test]
1212    fn scanner_filter_both_dimensions() {
1213        let f = ScannerFilter {
1214            namespace: Some(Namespace::new("tenant-a")),
1215            instance_tag: Some(("cairn.instance_id".into(), "i-1".into())),
1216        };
1217        assert!(f.matches(Some(&Namespace::new("tenant-a")), Some("i-1")));
1218        assert!(!f.matches(Some(&Namespace::new("tenant-a")), Some("i-2")));
1219        assert!(!f.matches(Some(&Namespace::new("tenant-b")), Some("i-1")));
1220        assert!(!f.matches(None, Some("i-1")));
1221    }
1222
1223    #[test]
1224    fn scanner_filter_builder_construction() {
1225        // Simulates external-crate usage: only public constructors
1226        // and chainable setters (no struct-literal access to the
1227        // `#[non_exhaustive]` fields).
1228        let empty = ScannerFilter::new();
1229        assert!(empty.is_noop());
1230        assert_eq!(empty, ScannerFilter::NOOP);
1231
1232        let ns_only = ScannerFilter::new().with_namespace(Namespace::new("tenant-a"));
1233        assert!(!ns_only.is_noop());
1234        assert!(ns_only.matches(Some(&Namespace::new("tenant-a")), None));
1235
1236        let tag_only = ScannerFilter::new().with_instance_tag("cairn.instance_id", "i-1");
1237        assert!(!tag_only.is_noop());
1238        assert!(tag_only.matches(None, Some("i-1")));
1239
1240        let both = ScannerFilter::new()
1241            .with_namespace(Namespace::new("tenant-a"))
1242            .with_instance_tag("cairn.instance_id", "i-1");
1243        assert!(both.matches(Some(&Namespace::new("tenant-a")), Some("i-1")));
1244        assert!(!both.matches(Some(&Namespace::new("tenant-b")), Some("i-1")));
1245    }
1246
1247    #[test]
1248    fn backend_config_valkey_ctor() {
1249        let c = BackendConfig::valkey("host.local", 6379);
1250        // Same-crate match against an otherwise `#[non_exhaustive]`
1251        // enum is irrefutable — no wildcard needed and `let-else`
1252        // would trip `irrefutable_let_patterns`.
1253        let BackendConnection::Valkey(v) = &c.connection;
1254        assert_eq!(v.host, "host.local");
1255        assert_eq!(v.port, 6379);
1256        assert!(!v.tls);
1257        assert!(!v.cluster);
1258        assert_eq!(c.timeouts, BackendTimeouts::default());
1259        assert_eq!(c.retry, BackendRetry::default());
1260        assert_eq!(c.clone(), c);
1261    }
1262}