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}