net-cli 0.34.0

Unified `net-mesh` command-line tool for the Net mesh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
//! `net cap (show|query|nodes|announce)` — capability advertisement
//! and discovery from the local snapshot, plus offline compose-and-sign
//! for v0.4 capability-auth (see `docs/internal/plans/CAPABILITY_AUTH_PLAN.md`).
//!
//! `show` / `query` / `nodes` read `DeckClient::status()` and filter
//! the snapshot's per-peer `capability_set`. `announce` builds a
//! signed [`CapabilityAnnouncement`] with the supplied allow-lists
//! and emits the JSON bytes to stdout (or `--out`); the operator
//! ships those bytes through any pub/sub path that calls
//! `CapabilityIndex::index` on receipt. Direct broadcast through
//! the CLI is deferred until the SDK exposes a mesh handle on the
//! daemon runtime — that's tracked separately and doesn't block
//! the operator from issuing announcements today.

use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

use clap::{Args, Subcommand};
use net_sdk::capabilities::{
    CapabilityAnnouncement, CapabilityGroupId as GroupId, CapabilitySet,
    CapabilitySubnetId as SubnetId, CapabilityTagError, Tag, MAX_ALLOW_LIST_LEN, RESERVED_PREFIXES,
};
use serde::Serialize;

use crate::context::{load_identity_keypair, resolve_profile, CliContext};
use crate::error::{generic, invalid_args, CliError};
use crate::prelude::{emit_value, OutputFormat};

#[derive(Subcommand, Debug)]
pub enum CapCommand {
    /// Show capabilities for the local node (default) or a
    /// specific peer via `--node`.
    Show(ShowArgs),
    /// Find nodes whose advertised capability set contains
    /// every supplied tag.
    Query(QueryArgs),
    /// List every (node, capabilities) tuple known to the local
    /// capability index.
    Nodes(NodesArgs),
    /// Build a signed `CapabilityAnnouncement` with the supplied
    /// allow-lists and emit the JSON bytes to stdout (or `--out`).
    ///
    /// Revocation: re-run with a tighter `--allow-node` /
    /// `--allow-subnet` / `--allow-group` set + a bumped
    /// `--version`; the new bytes supersede the old at any
    /// receiver that folds them. There is no separate `revoke`
    /// verb — that's the locked design (see
    /// `docs/internal/plans/CAPABILITY_AUTH_PLAN.md` §"Locked design points").
    Announce(AnnounceArgs),
}

#[derive(Args, Debug)]
pub struct ShowArgs {
    /// Peer node id. Defaults to the local node configured by
    /// `--node`.
    #[arg(long, value_name = "PEER_NODE")]
    pub peer: Option<u64>,

    #[arg(long)]
    pub identity: Option<PathBuf>,

    #[arg(long, default_value_t = crate::prelude::DEFAULT_SUPERVISOR_NODE)]
    pub node: u64,
}

#[derive(Args, Debug)]
pub struct QueryArgs {
    /// One or more required tags. A node matches when its
    /// advertised capability set contains every tag listed.
    #[arg(long = "tag", required = true, num_args = 1.., value_name = "TAG")]
    pub tags: Vec<String>,

    #[arg(long)]
    pub identity: Option<PathBuf>,

    #[arg(long, default_value_t = crate::prelude::DEFAULT_SUPERVISOR_NODE)]
    pub node: u64,
}

#[derive(Args, Debug)]
pub struct NodesArgs {
    #[arg(long)]
    pub identity: Option<PathBuf>,

    #[arg(long, default_value_t = crate::prelude::DEFAULT_SUPERVISOR_NODE)]
    pub node: u64,
}

#[derive(Args, Debug)]
pub struct AnnounceArgs {
    /// One or more capability tags to carry on the announcement
    /// (e.g. `nrpc:my-service`, `dataforts.blob.overflow`).
    ///
    /// Reserved-prefix tags ([`RESERVED_PREFIXES`] — `causal:` /
    /// `dataforts:` / `fork-of:` / `heat:` / `scope:`) are REJECTED:
    /// the command fails with an invalid-argument error rather than
    /// emitting an announcement that silently omits them. This differs
    /// from `CapabilitySet::add_tag`, which warns and drops — that path
    /// has a set to return, this one can still refuse to sign.
    ///
    /// Scope in particular cannot be set here. Use the SDK's dedicated
    /// builders (`with_tenant_scope` / `with_region_scope` /
    /// `with_subnet_local_scope`).
    #[arg(long = "tag", required = true, num_args = 1.., value_name = "TAG")]
    pub tags: Vec<String>,

    /// Allow-listed caller node ids. Accept decimal or `0x`-prefixed
    /// hex. Empty = permissive for this axis. Lists capped at 64
    /// entries per axis (`MAX_ALLOW_LIST_LEN`); past that operators
    /// should use a group.
    #[arg(long = "allow-node", num_args = 0.., value_name = "NODE_ID")]
    pub allow_nodes: Vec<String>,

    /// Allow-listed subnet ids — `<hex32>` or `subnet:<hex32>`.
    /// ROUTING ONLY: subnet membership is self-declared and this list
    /// is broadcast mesh-wide, so it narrows candidate selection and
    /// admits nobody. A capability restricted by this axis alone
    /// denies every caller. Use --allow-node for access control.
    #[arg(long = "allow-subnet", num_args = 0.., value_name = "SUBNET")]
    pub allow_subnets: Vec<String>,

    /// Allow-listed group ids — `<hex64>` or `group:<hex64>`.
    /// ADVISORY ONLY: group membership is self-declared and this list
    /// is broadcast mesh-wide. Use --allow-node for access control.
    #[arg(long = "allow-group", num_args = 0.., value_name = "GROUP")]
    pub allow_groups: Vec<String>,

    /// Operator identity TOML containing `seed_hex = "..."` (32
    /// bytes of hex). The keypair's derived `node_id` is used as
    /// the announcement's `node_id` unless `--node-id` overrides it.
    #[arg(long, value_name = "PATH")]
    pub key: PathBuf,

    /// Monotonic version. Receivers honor strictly-increasing
    /// versions per `node_id` — bumps on every revocation /
    /// policy change.
    #[arg(long, default_value_t = 1)]
    pub version: u64,

    /// TTL in seconds. The receiver caps its local lifetime at
    /// `min(local_ttl, origin_remaining)` so a replayed late
    /// announcement doesn't get a fresh local lease.
    #[arg(long = "ttl-secs", default_value_t = 300)]
    pub ttl_secs: u32,

    /// Override the derived `node_id` (decimal or `0x` hex). The
    /// default — `EntityKeypair::node_id()` — is the right
    /// answer for self-issued announcements; the override is for
    /// operator scenarios where the key signs on behalf of a
    /// different node identity.
    #[arg(long = "node-id", value_name = "NODE_ID")]
    pub node_id: Option<String>,

    /// Write the JSON announcement bytes here. Defaults to
    /// stdout.
    #[arg(long, value_name = "PATH")]
    pub out: Option<PathBuf>,
}

pub async fn run(
    cmd: CapCommand,
    output: Option<OutputFormat>,
    config_path: Option<&std::path::Path>,
    profile_name: &str,
) -> Result<(), CliError> {
    match cmd {
        CapCommand::Show(args) => run_show(args, output, config_path, profile_name).await,
        CapCommand::Query(args) => run_query(args, output, config_path, profile_name).await,
        CapCommand::Nodes(args) => run_nodes(args, output, config_path, profile_name).await,
        CapCommand::Announce(args) => run_announce(args).await,
    }
}

async fn run_show(
    args: ShowArgs,
    output: Option<OutputFormat>,
    config_path: Option<&std::path::Path>,
    profile_name: &str,
) -> Result<(), CliError> {
    let profile = resolve_profile(config_path, profile_name).await?;
    let ctx = CliContext::build(&profile, args.identity.as_deref(), args.node, false).await?;
    let snapshot = ctx.deck().status();
    let target = args.peer.unwrap_or(args.node);
    let caps = snapshot
        .peers
        .get(&target)
        .map(|p| p.capability_set.iter().cloned().collect::<Vec<_>>())
        .unwrap_or_default();
    let info = CapShow {
        node: target,
        capabilities: caps,
    };
    emit_value(OutputFormat::resolve_oneshot(output), &info)
        .map_err(|e| generic(format!("write cap show: {e}")))?;
    Ok(())
}

async fn run_query(
    args: QueryArgs,
    output: Option<OutputFormat>,
    config_path: Option<&std::path::Path>,
    profile_name: &str,
) -> Result<(), CliError> {
    let profile = resolve_profile(config_path, profile_name).await?;
    let ctx = CliContext::build(&profile, args.identity.as_deref(), args.node, false).await?;
    let snapshot = ctx.deck().status();
    let required: BTreeSet<String> = args.tags.into_iter().collect();
    let matches: Vec<u64> = snapshot
        .peers
        .iter()
        .filter(|(_, p)| required.iter().all(|t| p.capability_set.contains(t)))
        .map(|(id, _)| *id)
        .collect();
    let info = CapQuery {
        required: required.into_iter().collect(),
        matched_nodes: matches,
    };
    emit_value(OutputFormat::resolve_oneshot(output), &info)
        .map_err(|e| generic(format!("write cap query: {e}")))?;
    Ok(())
}

async fn run_nodes(
    args: NodesArgs,
    output: Option<OutputFormat>,
    config_path: Option<&std::path::Path>,
    profile_name: &str,
) -> Result<(), CliError> {
    let profile = resolve_profile(config_path, profile_name).await?;
    let ctx = CliContext::build(&profile, args.identity.as_deref(), args.node, false).await?;
    let snapshot = ctx.deck().status();
    let rows: Vec<CapNodesRow> = snapshot
        .peers
        .iter()
        .map(|(id, p)| CapNodesRow {
            node: *id,
            capabilities: p.capability_set.iter().cloned().collect(),
        })
        .collect();
    emit_value(OutputFormat::resolve_oneshot(output), &rows)
        .map_err(|e| generic(format!("write cap nodes: {e}")))?;
    Ok(())
}

/// Emitted when `--allow-subnet` / `--allow-group` are used, because an
/// operator reaching for them is almost certainly trying to restrict
/// access and those axes do not.
///
/// Every `--flag` named here must be a real long on [`AnnounceArgs`].
/// The first version of this warning named `--allow-subnets` /
/// `--allow-groups` / `--allow-nodes` — all plural, none of which exist —
/// so the one actionable instruction in a security warning was a clap
/// parse error. `advisory_warning_names_only_real_flags` pins it.
const ADVISORY_ALLOW_LIST_WARNING: &str = "\
warning: --allow-subnet / --allow-group do NOT admit anyone; they only narrow
         routing. Membership is self-declared and this announcement publishes
         the admitted values mesh-wide. A capability restricted by these axes
         alone denies every caller. Use --allow-node (or org admission) for
         access control.";

/// Rejection message for a `--tag` value the parser refused.
///
/// Branches on the error, because the remediation differs and only one
/// of them is about reserved prefixes. `--tag ""` used to be reported
/// with the reserved-prefix list and a pointer at the scope builders,
/// neither of which had anything to do with an empty string — the
/// operator was sent to read about `scope:` over a missing value.
///
/// The reserved-prefix arm builds its list from [`RESERVED_PREFIXES`]
/// rather than hand-writing it: the previous hard-coded list omitted
/// `dataforts:`, so a rejected `dataforts:foo` was reported against a
/// list not containing the prefix it tripped.
///
/// The match is exhaustive over [`CapabilityTagError`] so a new parser
/// error cannot silently inherit the reserved-prefix wording.
fn tag_rejected_message(tag: &str, err: &CapabilityTagError) -> String {
    match err {
        CapabilityTagError::ReservedPrefix { .. } => {
            let reserved = RESERVED_PREFIXES.join("` / `");
            format!(
                "tag {tag:?} rejected: {err}. Reserved prefixes \
                 (`{reserved}`) cannot be set here; scope needs the SDK \
                 builders (with_tenant_scope / with_region_scope / \
                 with_subnet_local_scope)."
            )
        }
        CapabilityTagError::Empty => {
            format!("tag {tag:?} rejected: {err}.")
        }
    }
}

/// Build the announcement's [`CapabilitySet`] from `--tag` values,
/// rejecting any the parser refuses.
///
/// Validates via `Tag::parse_user` and returns `Err` on the first
/// rejection, rather than handing the tag to `CapabilitySet::add_tag` —
/// which warns and DROPS, producing a signed announcement quietly
/// missing the tag the operator asked for. This path has the option of
/// refusing to sign, and takes it.
///
/// The parser result is used directly rather than a length delta on
/// `caps.tags.len()`: the pre-fix heuristic could not distinguish
/// "parser rejected the tag" from "tag was a duplicate already in the
/// set", so a legal `--tag nrpc:echo --tag nrpc:echo` errored out with
/// the reserved-prefix message. Duplicates dedupe silently through the
/// underlying `HashSet<Tag>`.
///
/// Extracted from `run_announce` so the contract is testable without a
/// keypair file and a daemon profile — the guard that matters is over
/// this behaviour, not over `Tag::parse_user` itself.
fn capability_set_from_tags(tags: &[String]) -> Result<CapabilitySet, CliError> {
    let mut caps = CapabilitySet::new();
    for tag in tags {
        if let Err(e) = Tag::parse_user(tag) {
            return Err(invalid_args(tag_rejected_message(tag, &e)));
        }
        caps = caps.add_tag(tag.clone());
    }
    Ok(caps)
}

async fn run_announce(args: AnnounceArgs) -> Result<(), CliError> {
    // 1. Identity. Reuses the same TOML loader the live
    //    `CliContext::build` path uses so an operator can point
    //    `--key` at the same file they already configured for
    //    other write-side subcommands.
    let keypair = load_identity_keypair(&args.key).await?;

    // 2. Allow-list parsing — fail loudly on any malformed entry
    //    before signing anything. Operators get a typed error per
    //    flag rather than a silent drop.
    if args.allow_nodes.len() > MAX_ALLOW_LIST_LEN
        || args.allow_subnets.len() > MAX_ALLOW_LIST_LEN
        || args.allow_groups.len() > MAX_ALLOW_LIST_LEN
    {
        return Err(invalid_args(format!(
            "allow-list axes are capped at {MAX_ALLOW_LIST_LEN} entries each; \
             operators above that limit should use a group instead of an \
             inline node enumeration (see CAPABILITY_AUTH_PLAN.md §\"What ships\")"
        )));
    }
    let allowed_nodes = parse_node_ids(&args.allow_nodes)?;
    let allowed_subnets = parse_subnets(&args.allow_subnets)?;
    let allowed_groups = parse_groups(&args.allow_groups)?;

    // The subnet / group axes read tags the CALLER declares about
    // itself, and this announcement publishes the admitted values to
    // every peer and relay within MAX_CAPABILITY_HOPS. Anyone who
    // receives it can claim a listed group or subnet with a one-line
    // `add_tag`. Since S1 (SUBNET_AUTH_PLAN.md) these axes no longer
    // admit at all — they narrow routing — so an announcement
    // restricted by them alone denies every caller. `--allow-node` is
    // the axis that holds.
    if !allowed_subnets.is_empty() || !allowed_groups.is_empty() {
        eprintln!("{ADVISORY_ALLOW_LIST_WARNING}");
    }

    // 3. Resolve target node_id. The keypair's derived `node_id`
    //    is the only value that round-trips through the receiver's
    //    `handle_capability_announcement` — receivers re-derive the
    //    expected NodeId from the signed `entity_id` and reject
    //    announcements where the carried `node_id` doesn't match.
    //    Allow `--node-id` only as an explicit confirmation (must
    //    equal the derived value); a mismatch is an operator error
    //    that would otherwise produce unusable bytes.
    let derived = keypair.node_id();
    let node_id = match args.node_id.as_deref() {
        Some(s) => {
            let supplied = parse_node_id(s)?;
            if supplied != derived {
                return Err(invalid_args(format!(
                    "--node-id {supplied:#x} does not match the signing key's \
                     derived node id {derived:#x}; receivers re-derive the \
                     expected NodeId from the signed entity_id and reject \
                     announcements with mismatched bindings. Drop the flag \
                     to use the derived value, or sign with the keypair that \
                     produces {supplied:#x}."
                )));
            }
            supplied
        }
        None => derived,
    };

    // 4. Build the CapabilitySet with the user-supplied tags.
    let caps = capability_set_from_tags(&args.tags)?;

    // 5. Build + sign.
    let mut ann =
        CapabilityAnnouncement::new(node_id, keypair.entity_id().clone(), args.version, caps)
            .with_ttl(args.ttl_secs);
    ann.allowed_nodes = allowed_nodes;
    ann.allowed_subnets = allowed_subnets;
    ann.allowed_groups = allowed_groups;
    ann.sign(&keypair);

    // 6. Emit JSON bytes. Operators pipe stdout or save via
    //    `--out`; downstream tooling parses with
    //    `CapabilityAnnouncement::from_bytes` and folds via
    //    `CapabilityIndex::index`.
    let bytes = ann.to_bytes();
    write_announcement_output(args.out.as_deref(), &bytes).await?;
    Ok(())
}

fn parse_node_ids(values: &[String]) -> Result<Vec<u64>, CliError> {
    values.iter().map(|v| parse_node_id(v)).collect()
}

fn parse_node_id(value: &str) -> Result<u64, CliError> {
    let trimmed = value.trim();
    let parsed = if let Some(hex) = trimmed
        .strip_prefix("0x")
        .or_else(|| trimmed.strip_prefix("0X"))
    {
        u64::from_str_radix(hex, 16)
    } else {
        trimmed.parse::<u64>()
    };
    parsed.map_err(|_| {
        invalid_args(format!(
            "node id {value:?} must be decimal or `0x`-prefixed hex (u64)"
        ))
    })
}

fn parse_subnets(values: &[String]) -> Result<Vec<SubnetId>, CliError> {
    values
        .iter()
        .map(|v| {
            // Trim once at the top so trailing whitespace (e.g.
            // from shell-pasted hex) behaves the same way it does
            // on `--allow-node`, where `parse_node_id` also trims.
            let trimmed = v.trim();
            let tag_form = if trimmed.starts_with("subnet:") {
                trimmed.to_string()
            } else {
                format!("subnet:{trimmed}")
            };
            SubnetId::from_tag(&tag_form).ok_or_else(|| {
                invalid_args(format!(
                    "subnet id {v:?} must be 32 hex characters (16 bytes), \
                     optionally prefixed with `subnet:`"
                ))
            })
        })
        .collect()
}

fn parse_groups(values: &[String]) -> Result<Vec<GroupId>, CliError> {
    values
        .iter()
        .map(|v| {
            // Symmetric with `parse_subnets` and `parse_node_id`.
            let trimmed = v.trim();
            let tag_form = if trimmed.starts_with("group:") {
                trimmed.to_string()
            } else {
                format!("group:{trimmed}")
            };
            GroupId::from_tag(&tag_form).ok_or_else(|| {
                invalid_args(format!(
                    "group id {v:?} must be 64 hex characters (32 bytes), \
                     optionally prefixed with `group:`"
                ))
            })
        })
        .collect()
}

async fn write_announcement_output(out: Option<&Path>, bytes: &[u8]) -> Result<(), CliError> {
    match out {
        Some(path) => tokio::fs::write(path, bytes)
            .await
            .map_err(|e| generic(format!("write {}: {e}", path.display()))),
        None => {
            use std::io::Write;
            let mut stdout = std::io::stdout().lock();
            stdout
                .write_all(bytes)
                .map_err(|e| generic(format!("write stdout: {e}")))?;
            // Trailing newline so a piped consumer can read a clean
            // line if it wants one; the JSON bytes themselves don't
            // terminate with a newline.
            stdout
                .write_all(b"\n")
                .map_err(|e| generic(format!("write stdout: {e}")))?;
            Ok(())
        }
    }
}

#[derive(Serialize)]
struct CapShow {
    node: u64,
    capabilities: Vec<String>,
}

#[derive(Serialize)]
struct CapQuery {
    required: Vec<String>,
    matched_nodes: Vec<u64>,
}

#[derive(Serialize)]
struct CapNodesRow {
    node: u64,
    capabilities: Vec<String>,
}

#[cfg(test)]
mod tests {
    //! Guards for the two operator-facing contracts on `cap announce`
    //! that had drifted from the code
    //! (CODE_REVIEW_2026_08_01_SCOPED_CAPABILITIES_REMEDIATION.md,
    //! findings 1 and 8).
    use super::*;

    /// Every long flag `AnnounceArgs` actually accepts.
    fn announce_long_flags() -> BTreeSet<String> {
        let cmd = AnnounceArgs::augment_args(clap::Command::new("announce"));
        cmd.get_arguments()
            .filter_map(|a| a.get_long().map(str::to_string))
            .collect()
    }

    /// The load-bearing guard. A security warning whose remediation
    /// advice does not parse is worse than no advice, and prose cannot
    /// be type-checked against `#[arg(long = ...)]` — so check it.
    ///
    /// Pre-fix the warning named `--allow-subnets` / `--allow-groups` /
    /// `--allow-nodes`; the real flags are all singular, so an operator
    /// following the one actionable line got a clap parse error.
    #[test]
    fn advisory_warning_names_only_real_flags() {
        let real = announce_long_flags();
        // Sanity: the parse below is only meaningful if it finds flags.
        assert!(
            real.contains("allow-node"),
            "expected --allow-node on AnnounceArgs; got {real:?}"
        );

        let mentioned: BTreeSet<String> = ADVISORY_ALLOW_LIST_WARNING
            .split_whitespace()
            .filter_map(|w| w.strip_prefix("--"))
            // Trim trailing punctuation (`--allow-node.` at a line end).
            .map(|w| w.trim_end_matches(['.', ',', ';', ':']).to_string())
            .filter(|w| !w.is_empty())
            .collect();
        assert!(
            !mentioned.is_empty(),
            "the warning names no flags at all — did the text change shape?"
        );

        for flag in &mentioned {
            assert!(
                real.contains(flag),
                "the advisory warning tells the operator to use `--{flag}`, \
                 which is not a flag on `cap announce`. Real flags: {real:?}"
            );
        }
    }

    /// The warning must still name the axis that actually holds —
    /// otherwise it diagnoses without remediating.
    #[test]
    fn advisory_warning_points_at_allow_node() {
        assert!(
            ADVISORY_ALLOW_LIST_WARNING.contains("--allow-node "),
            "the warning must direct the operator to --allow-node"
        );
    }

    /// Finding 8: the `--tag` doc promised warn-and-drop; the code
    /// refuses to sign. Pinned against the announce path's own
    /// tag-building step, not against `Tag::parse_user` — testing the
    /// parser would only restate a library guarantee, and would stay
    /// green if this command went back to `add_tag`-and-drop, which is
    /// the exact regression it is here to prevent.
    #[test]
    fn reserved_prefix_tags_are_rejected_not_dropped() {
        for prefix in RESERVED_PREFIXES {
            let tag = format!("{prefix}whatever");
            let err = capability_set_from_tags(std::slice::from_ref(&tag))
                .err()
                .unwrap_or_else(|| {
                    panic!("`{tag}` must fail the announce build, not be dropped from it")
                });
            let msg = err.to_string();
            assert!(
                msg.contains(&tag),
                "the rejection must name the offending tag; got {msg}"
            );
        }
    }

    /// The drop this guards against is silent, so absence-of-tag is the
    /// thing to assert: a rejected tag must not produce a set at all.
    #[test]
    fn a_reserved_tag_never_reaches_the_announcement() {
        let tags = vec![
            "nrpc:echo".to_string(),
            "scope:tenant:acme".to_string(),
            "gpu".to_string(),
        ];
        assert!(
            capability_set_from_tags(&tags).is_err(),
            "one reserved tag must fail the whole announcement rather than \
             signing the other two without it"
        );
    }

    /// The legal cases still work — including the duplicate that the
    /// pre-fix length-delta heuristic mistook for a rejection.
    #[test]
    fn ordinary_and_duplicate_tags_still_build() {
        let caps = capability_set_from_tags(&[
            "nrpc:echo".to_string(),
            "nrpc:echo".to_string(),
            "gpu".to_string(),
        ])
        .expect("legal tags must build");
        let rendered: Vec<String> = caps.tags.iter().map(|t| t.to_string()).collect();
        assert_eq!(
            rendered.len(),
            2,
            "duplicates dedupe through HashSet<Tag> rather than erroring; got {rendered:?}"
        );
    }

    /// The reserved-prefix message is built from `RESERVED_PREFIXES`, so
    /// it cannot omit a prefix the parser enforces. The hand-written
    /// version it replaced omitted `dataforts:`.
    #[test]
    fn tag_rejection_message_lists_every_reserved_prefix() {
        let err = Tag::parse_user("scope:tenant:acme").expect_err("reserved");
        let msg = tag_rejected_message("scope:tenant:acme", &err);
        for prefix in RESERVED_PREFIXES {
            assert!(
                msg.contains(prefix),
                "rejection message omits the reserved prefix `{prefix}`: {msg}"
            );
        }
    }

    /// An empty `--tag` is not a reserved-prefix problem, and must not
    /// be diagnosed as one. It previously inherited the whole
    /// reserved-prefix message, sending an operator who typed
    /// `--tag ""` off to read about `scope:` and the SDK scope builders.
    #[test]
    fn an_empty_tag_is_not_diagnosed_as_a_reserved_prefix() {
        let err = Tag::parse_user("").expect_err("empty tag must be rejected");
        let msg = tag_rejected_message("", &err);

        assert!(
            !msg.contains("Reserved prefixes"),
            "an empty tag must not be blamed on reserved prefixes: {msg}"
        );
        assert!(
            !msg.contains("with_tenant_scope"),
            "and must not point at the scope builders: {msg}"
        );
        assert!(
            msg.contains("non-empty"),
            "it should say what is actually wrong; got {msg}"
        );

        // And it still fails the announce build.
        assert!(capability_set_from_tags(&[String::new()]).is_err());
    }
}