Skip to main content

elasticctl_api/state/
push.rs

1//! `state push`: plan and apply, in container-then-item-then-rule order.
2
3use super::diff::{ItemOp, ListOp};
4use super::reports::{DanglingPointer, Mirror, PushReport, StackIdentity};
5use crate::diff::{Change, Drift};
6use crate::exceptions;
7use crate::model::{ExceptionItem, ListKey, Rule};
8use crate::normalize;
9use crate::report::{ChangeReport, ReportEntry};
10use crate::rules as api;
11use elasticctl_core::{Error, ErrorKind, Result, Transport};
12use serde_json::{Value, json};
13use std::collections::{BTreeMap, BTreeSet};
14use std::path::Path;
15
16/// What `plan_push` computed and `apply_push` performs.
17///
18/// The preview fields feed the caller's guard banner; `report` is the change
19/// ticket; `summary` is the JSON report.
20#[derive(Debug, Clone)]
21pub struct PushPlan {
22    pub preview_action: String,
23    pub preview_details: Vec<String>,
24    pub report: ChangeReport,
25    pub summary: PushReport,
26    /// The exact rules the preview described, resolved once at plan time so
27    /// `apply_push` never re-reads the mirror after the guard.
28    desired: BTreeMap<String, Rule>,
29    /// Container writes, ordered before any item or rule write.
30    list_ops: Vec<ListOp>,
31    /// Item creates, updates, and removals, ordered before rule writes.
32    item_ops: Vec<ItemOp>,
33}
34
35/// The exception writes `apply_push` performed, folded into `PushReport`.
36#[derive(Default, Clone, Copy)]
37struct ExceptionCounts {
38    lists_created: usize,
39    lists_updated: usize,
40    items_created: usize,
41    items_updated: usize,
42    items_removed: usize,
43}
44
45/// Compute the push preview and dry-run report without mutating the stack,
46/// scoped by `source`. The `custom`/`all` default lives on the clap flag, where
47/// `--help` shows it (spec 5.5).
48pub async fn plan_push(
49    t: &Transport,
50    dir: &Path,
51    selectors: &[String],
52    tag: Option<&str>,
53    search: Option<&str>,
54    source: crate::rules::RuleSource,
55    identity: &StackIdentity,
56) -> Result<PushPlan> {
57    let Mirror {
58        rules: local_all,
59        lists,
60        items,
61    } = super::mirror::read_mirror(dir)?;
62    // Resolve locally first because disk-only rules have no remote ID and may
63    // be created by a scoped push.
64    let scope = super::scope_of(t, selectors, tag, search, source, &local_all, "apply").await?;
65    // A local file outside the `--source` scope is never a pending create
66    // (spec 5.5). Selectors narrow both sides and leave the count at zero.
67    let (local, out_of_scope) = if scope.is_scoped() {
68        (scope.narrow(local_all), 0)
69    } else {
70        scope.split_by_source(local_all)
71    };
72    // A value-list reference is active only if its exception container is
73    // reachable from a rule in this push's active closure. Reading every
74    // mirror item here would let an out-of-scope rule block this preview.
75    let active_list_keys = super::referenced_keys(&local);
76    let value_lists = value_list_refs(&items, &active_list_keys);
77    let remote = scope.remote(t).await?;
78    let drift = Drift::compute(&local, &remote)?;
79
80    let plan = super::diff::exception_plan(t, lists, items, &local, &remote).await?;
81    let exceptions = plan.drift;
82    let list_ops = plan.list_ops;
83    let item_ops = plan.item_ops;
84    let resolvable = plan.resolvable;
85
86    let by_id = |id: &str| local.iter().find(|r| r.rule_id().ok() == Some(id)).cloned();
87    let remote_by_id = |id: &str| {
88        remote
89            .iter()
90            .find(|r| r.rule_id().ok() == Some(id))
91            .cloned()
92    };
93
94    let actionable = drift.actionable();
95    let actionable_ids: BTreeSet<String> =
96        actionable.iter().map(|c| c.rule_id().to_string()).collect();
97
98    // A dangling pointer is drift the normalized diff cannot see, so a rule
99    // whose normalized form is unchanged still needs a write to repair it.
100    // Remote-only rules are skipped: push never touches what it has no local
101    // form for. Dedupe by `rule_id`: a rule referencing two wrong pointers
102    // emits two `DanglingPointer`s but is one rule write.
103    let mut repairs: Vec<DanglingPointer> = Vec::new();
104    let mut repaired_ids: BTreeSet<String> = BTreeSet::new();
105    for dangling in &exceptions.dangling {
106        if actionable_ids.contains(&dangling.rule_id)
107            || by_id(&dangling.rule_id).is_none()
108            || !repaired_ids.insert(dangling.rule_id.clone())
109        {
110            continue;
111        }
112        repairs.push(dangling.clone());
113    }
114
115    let mut preview_details = Vec::new();
116
117    // Name containers and items first, matching apply order.
118    for op in &list_ops {
119        match op {
120            ListOp::Create(list) => {
121                preview_details.push(format!("{}  {}  create", list.list_id()?, list.name()))
122            }
123            ListOp::Update { after, .. } => {
124                preview_details.push(format!("{}  {}  update", after.list_id()?, after.name()))
125            }
126        }
127    }
128    for op in &item_ops {
129        match op {
130            ItemOp::Create(item) => {
131                preview_details.push(format!("{}  {}  create", item.item_id()?, item.list_id()?))
132            }
133            ItemOp::Update { after, .. } => preview_details.push(format!(
134                "{}  {}  update",
135                after.item_id()?,
136                after.list_id()?
137            )),
138            ItemOp::Remove { before, .. } => preview_details.push(format!(
139                "{}  {}  delete",
140                before.item_id()?,
141                before.list_id()?
142            )),
143        }
144    }
145    for change in &actionable {
146        let line = match change {
147            Change::Added { rule_id, name } => format!("{rule_id}  {name}  create"),
148            Change::Modified {
149                rule_id,
150                name,
151                fields,
152            } => {
153                let names: Vec<&str> = fields.iter().map(|f| f.field.as_str()).collect();
154                format!("{rule_id}  {name}  update ({})", names.join(", "))
155            }
156            _ => String::new(),
157        };
158        if !line.is_empty() {
159            preview_details.push(line);
160        }
161    }
162    for dangling in &repairs {
163        let name = by_id(&dangling.rule_id)
164            .map(|r| r.name().to_string())
165            .unwrap_or_default();
166        preview_details.push(format!("{}  {}  update (pointer)", dangling.rule_id, name));
167    }
168
169    // A value list is data, not configuration, and its content is not managed
170    // here (spec 7.7), but a referenced value list that cannot exist must be
171    // reported, never silently pushed. Absence is judged on the data streams:
172    // when they are not bootstrapped, no value list can exist. The `?` refuses
173    // on any failure that is not a clean "absent" 404, so an unverifiable
174    // reference is a failure rather than a silent omission.
175    if !value_lists.is_empty() {
176        if !exceptions::value_lists_bootstrapped(t).await? {
177            for value_list in &value_lists {
178                preview_details.push(format!(
179                    "value list \"{}\" is absent; run POST /api/lists/index to bootstrap the data streams",
180                    value_list.id
181                ));
182            }
183        } else {
184            for value_list in &value_lists {
185                if !exceptions::value_list_exists(t, &value_list.id).await? {
186                    preview_details.push(format!("value list \"{}\" is absent", value_list.id));
187                }
188            }
189        }
190    }
191
192    let mut entries: Vec<ReportEntry> = Vec::new();
193    let mut desired: BTreeMap<String, Rule> = BTreeMap::new();
194
195    // Record remote-only rules before applying changes, including in dry runs.
196    // `actionable()` excludes them because push never deletes remote rules.
197    for change in &drift.changes {
198        if let Change::RemoteOnly { rule_id, name } = change {
199            entries.push(ReportEntry {
200                rule_id: rule_id.clone(),
201                name: name.clone(),
202                action: "skipped_remote_only".into(),
203                before: remote_by_id(rule_id).map(|r| normalize::canonical(&r).into_value()),
204                after: None,
205                applied: false,
206                error: None,
207            });
208        }
209    }
210
211    // Record every actionable change as a pending entry. The report and JSON
212    // `pending` count describe proposed creates and updates.
213    for change in &actionable {
214        let (rule_id, name, action) = match change {
215            Change::Added { rule_id, name } => (rule_id.clone(), name.clone(), "create"),
216            Change::Modified { rule_id, name, .. } => (rule_id.clone(), name.clone(), "update"),
217            _ => continue,
218        };
219
220        let Some(desired_rule) = by_id(&rule_id) else {
221            continue;
222        };
223        let before = remote_by_id(&rule_id).map(|r| normalize::canonical(&r).into_value());
224
225        desired.insert(rule_id.clone(), desired_rule.clone());
226
227        entries.push(ReportEntry {
228            rule_id,
229            name,
230            action: action.into(),
231            before,
232            after: Some(normalize::canonical(&desired_rule).into_value()),
233            applied: false,
234            error: None,
235        });
236    }
237
238    // A repaired pointer is an update whose `before`/`after` normalization
239    // cannot show the difference; the write still happens.
240    for dangling in &repairs {
241        let desired_rule = by_id(&dangling.rule_id).expect("repair rule was found above");
242        let before = remote_by_id(&dangling.rule_id).map(|r| normalize::canonical(&r).into_value());
243        desired.insert(dangling.rule_id.clone(), desired_rule.clone());
244        entries.push(ReportEntry {
245            rule_id: dangling.rule_id.clone(),
246            name: desired_rule.name().to_string(),
247            action: "update".into(),
248            before,
249            after: Some(normalize::canonical(&desired_rule).into_value()),
250            applied: false,
251            error: None,
252        });
253    }
254
255    // Refuse, before any write, a rule that references a list neither on the
256    // stack nor in the mirror. The ids are injected at write time against the
257    // target, but resolvability is known here.
258    let desired_rules: Vec<Rule> = desired.values().cloned().collect();
259    let unresolved: Vec<String> = super::referenced_keys(&desired_rules)
260        .into_iter()
261        .filter(|key| !resolvable.contains(key))
262        .map(|key| format!("\"{}\" ({})", key.list_id, key.namespace_type))
263        .collect();
264    if !unresolved.is_empty() {
265        return Err(Error::new(
266            ErrorKind::NotFound,
267            format!(
268                "rule(s) reference exception list(s) that do not exist on this stack and are \
269                 not in the mirror: {}",
270                unresolved.join(", ")
271            ),
272        ));
273    }
274
275    // Name the selection so a scoped preview differs from a full preview. The
276    // banner names rule, list, and item counts (spec 6.1). Removals get their
277    // own count: the number an operator reads before `--yes` must not read the
278    // same for a run that deletes three items and one that creates three.
279    let item_removals = item_ops
280        .iter()
281        .filter(|op| matches!(op, ItemOp::Remove { .. }))
282        .count();
283    let item_writes = item_ops.len() - item_removals;
284    let mut preview_action = format!(
285        "Push {} rule change(s), {} exception list(s) and {} item(s)",
286        actionable.len() + repairs.len(),
287        list_ops.len(),
288        item_writes,
289    );
290    if item_removals > 0 {
291        preview_action.push_str(&format!(", {} item deletion(s)", item_removals));
292    }
293    preview_action.push_str(&format!(" from {}{}", dir.display(), scope.describe()));
294
295    let report = ChangeReport {
296        profile: identity.profile.clone(),
297        host: identity.host.clone(),
298        space: identity.space.clone(),
299        applied: false,
300        entries,
301    };
302    let summary = push_summary(
303        &report,
304        scope.is_scoped().then(|| scope.selected()),
305        scope.is_scoped().then_some(scope.local_total),
306        out_of_scope,
307        ExceptionCounts::default(),
308    );
309
310    Ok(PushPlan {
311        preview_action,
312        preview_details,
313        report,
314        summary,
315        desired,
316        list_ops,
317        item_ops,
318    })
319}
320
321/// Perform the mutations `plan_push` proposed.
322///
323/// The caller runs this only after its guard approves; a caller that never
324/// calls it has performed a dry run by construction. It reads only from the
325/// plan, never the mirror, so the preview and the apply cannot diverge.
326pub async fn apply_push(t: &Transport, mut plan: PushPlan) -> Result<PushPlan> {
327    // Resolve the live ids for every list a rule to write references, then
328    // create or update containers, then items, then rules. The pointer is
329    // injected only here, against the target stack, never at plan time.
330    let desired_rules: Vec<Rule> = plan.desired.values().cloned().collect();
331    let wanted: Vec<ListKey> = super::referenced_keys(&desired_rules).into_iter().collect();
332    let mut resolved = exceptions::resolve_ids(t, &wanted).await?;
333
334    let mut counts = ExceptionCounts::default();
335    let mut exception_entries = Vec::with_capacity(plan.list_ops.len() + plan.item_ops.len());
336
337    // 1. Containers. A failure records the evidence and stops: the ordering
338    // invariant means later writes depend on this one.
339    for op in &plan.list_ops {
340        let failure = match op {
341            ListOp::Create(list) => match exceptions::create_list(t, list).await {
342                Ok(created) => {
343                    if let Some(id) = created.as_map().get("id").and_then(Value::as_str) {
344                        resolved.insert(list.key()?, id.to_string());
345                    }
346                    counts.lists_created += 1;
347                    exception_entries.push(ReportEntry {
348                        rule_id: list.list_id().unwrap_or("<unreadable>").to_string(),
349                        name: list.name().to_string(),
350                        action: "create_list".into(),
351                        before: None,
352                        after: Some(normalize::canonical_list(&created).into_value()),
353                        applied: true,
354                        error: None,
355                    });
356                    None
357                }
358                Err(e) => Some(ReportEntry {
359                    rule_id: list.list_id().unwrap_or("<unreadable>").to_string(),
360                    name: list.name().to_string(),
361                    action: "create_list".into(),
362                    before: None,
363                    after: None,
364                    applied: false,
365                    error: Some(e.message),
366                }),
367            },
368            ListOp::Update { before, after } => match exceptions::update_list(t, after).await {
369                Ok(applied) => {
370                    counts.lists_updated += 1;
371                    exception_entries.push(ReportEntry {
372                        rule_id: after.list_id().unwrap_or("<unreadable>").to_string(),
373                        name: after.name().to_string(),
374                        action: "update_list".into(),
375                        before: Some(before.clone().into_value()),
376                        after: Some(normalize::canonical_list(&applied).into_value()),
377                        applied: true,
378                        error: None,
379                    });
380                    None
381                }
382                Err(e) => Some(ReportEntry {
383                    rule_id: after.list_id().unwrap_or("<unreadable>").to_string(),
384                    name: after.name().to_string(),
385                    action: "update_list".into(),
386                    before: Some(before.clone().into_value()),
387                    after: None,
388                    applied: false,
389                    error: Some(e.message),
390                }),
391            },
392        };
393        if let Some(failed_entry) = failure {
394            return Ok(finish_after_exception_failure(
395                plan,
396                exception_entries,
397                failed_entry,
398                counts,
399            ));
400        }
401    }
402
403    // 2. Items: create, update, or delete. A failure records the evidence and
404    // stops, like a container failure; a retry re-plans against the partial
405    // state and re-converges.
406    for op in &plan.item_ops {
407        let failure = match op {
408            ItemOp::Create(item) => match exceptions::create_item(t, item).await {
409                Ok(applied) => {
410                    counts.items_created += 1;
411                    exception_entries.push(ReportEntry {
412                        rule_id: item.item_id().unwrap_or("<unreadable>").to_string(),
413                        name: item.list_id().unwrap_or("<unreadable>").to_string(),
414                        action: "create_item".into(),
415                        before: None,
416                        after: Some(normalize::canonical_item(&applied).into_value()),
417                        applied: true,
418                        error: None,
419                    });
420                    None
421                }
422                Err(e) => Some(ReportEntry {
423                    rule_id: item.item_id().unwrap_or("<unreadable>").to_string(),
424                    name: item.list_id().unwrap_or("<unreadable>").to_string(),
425                    action: "create_item".into(),
426                    before: None,
427                    after: None,
428                    applied: false,
429                    error: Some(e.message),
430                }),
431            },
432            ItemOp::Update { before, after } => match exceptions::update_item(t, after).await {
433                Ok(applied) => {
434                    counts.items_updated += 1;
435                    exception_entries.push(ReportEntry {
436                        rule_id: after.item_id().unwrap_or("<unreadable>").to_string(),
437                        name: after.list_id().unwrap_or("<unreadable>").to_string(),
438                        action: "update_item".into(),
439                        before: Some(before.clone().into_value()),
440                        after: Some(normalize::canonical_item(&applied).into_value()),
441                        applied: true,
442                        error: None,
443                    });
444                    None
445                }
446                Err(e) => Some(ReportEntry {
447                    rule_id: after.item_id().unwrap_or("<unreadable>").to_string(),
448                    name: after.list_id().unwrap_or("<unreadable>").to_string(),
449                    action: "update_item".into(),
450                    before: Some(before.clone().into_value()),
451                    after: None,
452                    applied: false,
453                    error: Some(e.message),
454                }),
455            },
456            ItemOp::Remove {
457                before,
458                namespace_type,
459            } => match exceptions::delete_item(
460                t,
461                before.item_id().unwrap_or("<unreadable>"),
462                namespace_type,
463            )
464            .await
465            {
466                Ok(_) => {
467                    counts.items_removed += 1;
468                    exception_entries.push(ReportEntry {
469                        rule_id: before.item_id().unwrap_or("<unreadable>").to_string(),
470                        name: before.list_id().unwrap_or("<unreadable>").to_string(),
471                        action: "delete_item".into(),
472                        before: Some(before.clone().into_value()),
473                        after: None,
474                        applied: true,
475                        error: None,
476                    });
477                    None
478                }
479                Err(e) => Some(ReportEntry {
480                    rule_id: before.item_id().unwrap_or("<unreadable>").to_string(),
481                    name: before.list_id().unwrap_or("<unreadable>").to_string(),
482                    action: "delete_item".into(),
483                    before: Some(before.clone().into_value()),
484                    after: None,
485                    applied: false,
486                    error: Some(e.message),
487                }),
488            },
489        };
490        if let Some(failed_entry) = failure {
491            return Ok(finish_after_exception_failure(
492                plan,
493                exception_entries,
494                failed_entry,
495                counts,
496            ));
497        }
498    }
499
500    // 3. Rules, injecting the resolved pointer into each.
501    let mut entries = exception_entries;
502    entries.reserve(plan.report.entries.len());
503    for entry in plan.report.entries {
504        if entry.action != "create" && entry.action != "update" {
505            // `skipped_remote_only` entries pass through untouched.
506            entries.push(entry);
507            continue;
508        }
509
510        let Some(desired) = plan.desired.get(&entry.rule_id) else {
511            // `plan_push` records a desired rule for every actionable change,
512            // so this is defensive. Record the inconsistency as a failure
513            // rather than dropping the planned mutation from the report.
514            let missing = entry.rule_id.clone();
515            entries.push(ReportEntry {
516                rule_id: entry.rule_id,
517                name: entry.name,
518                action: entry.action,
519                before: entry.before,
520                after: None,
521                applied: false,
522                error: Some(format!("the plan has no desired rule for \"{missing}\"")),
523            });
524            continue;
525        };
526
527        let mut to_write = desired.clone();
528        // `plan_push` verified resolvability, so a miss here is a container
529        // whose live id could not be read (or a list deleted since planning).
530        // Record it per-rule, like any other write failure, and continue.
531        if let Err(e) = inject_list_ids(&mut to_write, &resolved) {
532            entries.push(ReportEntry {
533                rule_id: entry.rule_id,
534                name: entry.name,
535                action: entry.action,
536                before: entry.before,
537                after: None,
538                applied: false,
539                error: Some(e.message),
540            });
541            continue;
542        }
543        let before = entry.before;
544        let is_create = entry.action == "create";
545
546        // Continue after a per-rule failure so the report records every
547        // outcome.
548        let outcome = if is_create {
549            api::create(t, &to_write).await
550        } else {
551            api::update(t, &to_write).await
552        };
553
554        match outcome {
555            Ok(applied) => entries.push(ReportEntry {
556                rule_id: entry.rule_id,
557                name: entry.name,
558                action: entry.action,
559                before,
560                after: Some(normalize::canonical(&applied).into_value()),
561                applied: true,
562                error: None,
563            }),
564            Err(e) => entries.push(ReportEntry {
565                rule_id: entry.rule_id,
566                name: entry.name,
567                action: entry.action,
568                before,
569                after: None,
570                applied: false,
571                error: Some(e.message),
572            }),
573        }
574    }
575
576    let (selected, local_total, out_of_scope) = (
577        plan.summary.selected,
578        plan.summary.local_total,
579        plan.summary.out_of_scope,
580    );
581    plan.report.entries = entries;
582    plan.report.applied = true;
583    plan.summary = push_summary(&plan.report, selected, local_total, out_of_scope, counts);
584    Ok(plan)
585}
586
587/// Record a failed exception write in the change ticket and finalize the plan,
588/// returning it so the caller keeps the evidence of what landed before the
589/// failure.
590fn finish_after_exception_failure(
591    mut plan: PushPlan,
592    mut exception_entries: Vec<ReportEntry>,
593    failed_entry: ReportEntry,
594    counts: ExceptionCounts,
595) -> PushPlan {
596    exception_entries.push(failed_entry);
597    exception_entries.append(&mut plan.report.entries);
598    plan.report.entries = exception_entries;
599    plan.report.applied = true;
600    let (selected, local_total, out_of_scope) = (
601        plan.summary.selected,
602        plan.summary.local_total,
603        plan.summary.out_of_scope,
604    );
605    plan.summary = push_summary(&plan.report, selected, local_total, out_of_scope, counts);
606    plan
607}
608
609fn push_summary(
610    report: &ChangeReport,
611    selected: Option<usize>,
612    local_total: Option<usize>,
613    out_of_scope: usize,
614    counts: ExceptionCounts,
615) -> PushReport {
616    let (created, updated, skipped, failed) = report.counts();
617    PushReport {
618        applied: report.applied,
619        created,
620        updated,
621        skipped_remote_only: skipped,
622        failed,
623        pending: report.pending(),
624        lists_created: counts.lists_created,
625        lists_updated: counts.lists_updated,
626        items_created: counts.items_created,
627        items_updated: counts.items_updated,
628        items_removed: counts.items_removed,
629        out_of_scope,
630        selected,
631        local_total,
632    }
633}
634
635/// Inject each referenced list's live `id` into the rule.
636///
637/// Measured fact 3: `id` is required on create and validated by nothing, so a
638/// fabricated or carried pointer would be stored silently. Resolve against this
639/// stack every time. `plan_push` has already refused a list that is neither on
640/// the stack nor in the mirror, so a miss here means the live id could not be
641/// read, not that the list is absent.
642fn inject_list_ids(rule: &mut Rule, live: &BTreeMap<ListKey, String>) -> Result<()> {
643    let Some(Value::Array(refs)) = rule.as_map_mut().get_mut("exceptions_list") else {
644        return Ok(());
645    };
646    for reference in refs.iter_mut() {
647        let Value::Object(map) = reference else {
648            continue;
649        };
650        let Some(list_id) = map.get("list_id").and_then(Value::as_str) else {
651            continue;
652        };
653        let namespace = map
654            .get("namespace_type")
655            .and_then(Value::as_str)
656            .unwrap_or("single");
657        let key = ListKey {
658            list_id: list_id.to_string(),
659            namespace_type: namespace.to_string(),
660        };
661        match live.get(&key) {
662            Some(id) => {
663                map.insert("id".into(), json!(id));
664            }
665            None => {
666                return Err(Error::new(
667                    ErrorKind::NotFound,
668                    format!(
669                        "rule references exception list \"{list_id}\" ({namespace}), whose live \
670                         id could not be resolved on this stack"
671                    ),
672                ));
673            }
674        }
675    }
676    Ok(())
677}
678
679/// The value-list ids an exception item's entries reference.
680///
681/// A `list` entry references a value list through `list.id`, a caller-supplied
682/// id that is stable across stacks (spec 7.7). A `BTreeSet` keeps the preview
683/// order deterministic.
684fn value_list_refs(
685    items: &[ExceptionItem],
686    active_list_keys: &BTreeSet<ListKey>,
687) -> BTreeSet<exceptions::ValueListRef> {
688    let mut ids = BTreeSet::new();
689    for item in items {
690        let Ok(list_id) = item.list_id() else {
691            continue;
692        };
693        let key = ListKey {
694            list_id: list_id.to_string(),
695            namespace_type: item.namespace_type().to_string(),
696        };
697        if !active_list_keys.contains(&key) {
698            continue;
699        }
700        let Some(entries) = item.as_map().get("entries").and_then(Value::as_array) else {
701            continue;
702        };
703        for entry in entries {
704            let Some(obj) = entry.as_object() else {
705                continue;
706            };
707            if obj.get("type").and_then(Value::as_str) != Some("list") {
708                continue;
709            }
710            if let Some(id) = obj
711                .get("list")
712                .and_then(Value::as_object)
713                .and_then(|l| l.get("id"))
714                .and_then(Value::as_str)
715            {
716                ids.insert(exceptions::ValueListRef { id: id.to_string() });
717            }
718        }
719    }
720    ids
721}