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