lgwks_bot 2.2.0

Capability-gated automation bots on a change-detecting ECS schedule: Observe, Evaluate, Execute, and Query, with an async runtime facade.
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
//! The `domain_id -> constructor` registry: what a spec's strings resolve to.
//!
//! A [`BotSpec`] carries identifiers, not code. A chain's
//! `source` and an action's `domain` are strings such as `"github::pr_status"`,
//! and the `target` beside each is a parameter whose meaning the domain itself
//! defines. Something has to turn those strings back into a running
//! [`Observe`] or [`Execute`], and
//! that is this module.
//!
//! # One list, in one place
//!
//! A registry is declared once, with [`domains!`](crate::domains), and that is
//! the only entry point. The list is data rather than registration: it is a
//! `static`, built at compile time, with no constructor to call and no global to
//! mutate, so two components cannot race to register a domain and the set a
//! binary can run is readable from its source rather than from its behaviour.
//! [`DomainRegistry`] carries the worked example.
//!
//! # What the registry does not decide
//!
//! A registry answers *which constructor* an identifier names. It never decides
//! what a bot is permitted to reach: authority still comes from the
//! [`GrantSet`] the caller holds, and every erased verb checks
//! its own caps at the call site. A spec therefore cannot grant itself anything
//! by naming a domain, which is what makes it safe to accept a spec from wire
//! data at all.
//!
//! # Uniqueness
//!
//! Identifiers are expected to be unique within a role, and the boundary
//! between "refused" and "first wins" is exactly this:
//!
//! - **Construction is validated.** [`DomainRegistry::validate`] refuses a
//!   registry that declares one identifier twice in the same role, naming both
//!   positions, and every build path — [`DomainRegistry::build_source`],
//!   [`DomainRegistry::build_action`] and the `domains!` macro — goes through
//!   it, so no registry with a duplicate can be constructed. Letting the first
//!   declaration win would make dispatch depend on declaration order.
//! - **The lookup accessors refuse ambiguity too.** [`DomainRegistry::source`]
//!   and [`DomainRegistry::action`] return `None` for an identifier declared
//!   more than once in their list, exactly as they do for an absent one; neither
//!   ever resolves to "whichever constructor was declared first", so a caller
//!   that skips [`DomainRegistry::validate`] still cannot reach an ambiguous
//!   constructor. They are the accessors, not an alternative construction path.
//!
//! The two roles are checked independently: one identifier appearing once as a
//! source and once as an action is one domain with two roles, not a duplicate.
//!
//! Exercised by `tests/registry.rs`:
//! `a_duplicate_source_identifier_is_refused_with_both_positions`,
//! `a_duplicate_action_identifier_is_refused_the_same_way`,
//! `validate_names_the_first_duplicate_pair_and_passes_clean_lists` and
//! `an_ambiguous_identifier_is_not_resolved_by_declaration_order`; the
//! downstream consequence by `tests/spec_materialize.rs`'s
//! `a_duplicate_registry_is_refused_naming_the_identifier`.

use std::any::Any;

use crate::cap::Cap;
use crate::ecs::{AdmittedInput, identify_output, same_output};
use crate::effect::InputIdentity;
use crate::error::BotError;
use crate::spec::{EvaluateAny, ExecuteAny, ObserveAny, TypedEval, TypedExec, Witness};
use crate::verb::{Evaluate, Execute, Observe};

/// Builds one source from the `target` its spec names.
///
/// A function pointer rather than a closure: the registry is a `static`, and a
/// capturing closure cannot live in one.
pub type SourceCtor = fn(&str) -> Result<Source, BotError>;

/// Builds one action from the `target` its spec names.
///
/// A function pointer for the same reason as [`SourceCtor`].
pub type ActionCtor = fn(&str) -> Result<Action, BotError>;

/// A source, erased to the view the runner calls, plus the metadata a chain
/// needs and the type a condition must be built against.
///
/// The erase happens at the registry boundary because that is the last point at
/// which the concrete type is known, and it is also where the output's
/// `PartialEq` can still be demanded — see [`Source::new`]. The metadata the
/// chain needs (`same`, `identify`, `witness`) is captured in the same breath,
/// because it is a function of `O::Output` and this constructor is the last
/// place that type is a type parameter.
///
/// # Why the type key travels with the source
///
/// A spec names a condition as text, and a condition is generic over the value
/// it evaluates ([`Changed<T>`](crate::domain::eval::Changed) compares two
/// `T`s). The document cannot carry `T`, and a `TypeId` is process-local, so the
/// only durable statement of the type is the source that produces it: this
/// handle answers [`Source::condition`] for its *own* output type, so a
/// condition identifier resolves against the type it will actually see rather
/// than against a guess. See [`InputIdentity::SCHEMA_ID`] for the durable key
/// the change filter binds, and [`Source::new`] for the bounds this needs.
///
/// [`InputIdentity::SCHEMA_ID`]: crate::effect::InputIdentity::SCHEMA_ID
pub struct Source {
    /// The observer, erased to the view the runner calls.
    inner: Box<dyn ObserveAny>,
    /// Equality for this source's output, captured from `O::Output: PartialEq`
    /// where the type was still a parameter.
    same: fn(&dyn Any, &dyn Any) -> bool,
    /// The admitted-input identity of this source's output.
    identify: fn(&dyn Any) -> AdmittedInput,
    /// What type this source produces, taken here where it is a parameter.
    witness: Witness,
    /// Builds a condition for this source's own output type from a wire
    /// identifier, so a spec's condition resolves against the type it will see.
    condition: fn(&str) -> Result<Condition, BotError>,
}

impl Source {
    /// Erase a concrete source into the handle a registry entry returns.
    ///
    /// The bounds are what any chain already needs: the ECS builder demands
    /// `PartialEq + InputIdentity` for its change filter and its admitted-input
    /// identity, and `Clone` is what [`Changed`](crate::domain::eval::Changed)
    /// keeps the previous value with. A source built here answers the
    /// order-free wire conditions, `changed` and `always`, for any output type,
    /// a struct included. A source whose output is ordered and parseable uses
    /// [`Source::ordered`] to answer the threshold conditions as well.
    #[must_use]
    pub fn new<O>(source: O) -> Self
    where
        O: Observe + 'static,
        O::Output: Clone + PartialEq + InputIdentity + 'static,
    {
        Self::erase(source, make_condition::<O>)
    }

    /// Erase a source whose output is ordered, so a spec may also name
    /// `threshold::above(<n>)` and `threshold::below(<n>)` against it.
    ///
    /// `PartialOrd` is what [`Above`](crate::domain::eval::Above) and
    /// [`Below`](crate::domain::eval::Below) compare with, and `FromStr` is how
    /// the threshold's argument is read from wire text as the source's own
    /// output type. Kept apart from [`Source::new`] so those bounds bind only
    /// the sources that offer thresholds, rather than every source a registry
    /// can hold.
    #[must_use]
    pub fn ordered<O>(source: O) -> Self
    where
        O: Observe + 'static,
        O::Output: Clone + PartialEq + PartialOrd + std::str::FromStr + InputIdentity + 'static,
        // Propagated from `parse_bound`: a threshold argument that fails to
        // parse is reported with the parse error's own text, so an output type
        // whose parse error cannot be rendered cannot be a threshold bound.
        <O::Output as std::str::FromStr>::Err: std::fmt::Display,
    {
        Self::erase(source, make_ordered_condition::<O>)
    }

    /// Capture the chain metadata where `O::Output` is still a type
    /// parameter, with the condition vocabulary the caller chose.
    fn erase<O>(source: O, condition: fn(&str) -> Result<Condition, BotError>) -> Self
    where
        O: Observe + 'static,
        O::Output: Clone + PartialEq + InputIdentity + 'static,
    {
        Self {
            inner: Box::new(source),
            same: same_output::<O>,
            identify: identify_output::<O>,
            witness: Witness::of::<O::Output>(),
            condition,
        }
    }

    /// The domain identifier the source declares for itself.
    #[must_use]
    pub fn domain_id(&self) -> &str {
        self.inner.domain_id()
    }

    /// The capabilities this source requires, for admission.
    #[must_use]
    pub fn required_caps(&self) -> &[Cap] {
        self.inner.required_caps()
    }

    /// Build the condition a spec names, for this source's output type.
    ///
    /// The vocabulary is closed and versioned with the type it evaluates:
    /// `changed` and `always` for every source, plus `threshold::above(<n>)`
    /// and `threshold::below(<n>)` for one built with [`Source::ordered`]. An
    /// identifier outside it is
    /// [`BotError::UnknownCondition`] — never a silently always-true condition,
    /// because an inert gate is how an effect a person expected to guard fires
    /// anyway.
    ///
    /// # Errors
    ///
    /// [`BotError::UnknownCondition`] when `condition_id` is not in the
    /// vocabulary, or when a threshold's argument does not parse as this
    /// source's output type.
    pub fn condition(&self, condition_id: &str) -> Result<Condition, BotError> {
        (self.condition)(condition_id)
    }

    /// Split the handle into the erased observer and the chain metadata.
    ///
    /// The one seam the ECS chain assembler uses. Kept crate-internal because
    /// the pieces are the substrate's own types, not a second public surface.
    pub(crate) fn into_parts(self) -> SourceParts {
        (self.inner, self.same, self.identify, self.witness)
    }
}

/// The erased pieces of a [`Source`]: the observer, then the `same`,
/// `identify` and `witness` metadata a chain needs.
///
/// One name for the tuple, so [`Source::into_parts`] and the chain assembler
/// agree on the order without either spelling out four types.
pub(crate) type SourceParts = (
    Box<dyn ObserveAny>,
    fn(&dyn Any, &dyn Any) -> bool,
    fn(&dyn Any) -> AdmittedInput,
    Witness,
);

/// Build the order-free wire condition `condition_id` names, for source `O`.
///
/// Monomorphized on `O`, so `O::Output` is the concrete type the condition
/// evaluates and no schema table has to be matched by hand.
fn make_condition<O>(condition_id: &str) -> Result<Condition, BotError>
where
    O: Observe + 'static,
    O::Output: Clone + PartialEq + 'static,
{
    let identifier = condition_id.trim();
    match identifier {
        "changed" => Ok(Condition::new::<O::Output, _>(
            crate::domain::eval::Changed::new(),
        )),
        "always" => Ok(Condition::new::<O::Output, _>(|_: &O::Output| true)),
        _ => Err(BotError::UnknownCondition {
            condition: identifier.to_owned(),
            argument_parse: String::from("not an identifier this build knows"),
        }),
    }
}

/// Build the wire condition `condition_id` names for an ordered source `O`:
/// the two thresholds, then the order-free vocabulary of [`make_condition`].
fn make_ordered_condition<O>(condition_id: &str) -> Result<Condition, BotError>
where
    O: Observe + 'static,
    O::Output: Clone + PartialEq + PartialOrd + std::str::FromStr + 'static,
    // Propagated from `parse_bound`, which carries the parse failure into the
    // error rather than dropping it. An output type whose `FromStr` error cannot
    // be rendered cannot be used as a threshold bound, because a caller
    // repairing a bad spec would have nothing to read.
    <O::Output as std::str::FromStr>::Err: std::fmt::Display,
{
    let identifier = condition_id.trim();
    if let Some(argument) = parenthesized(identifier, "threshold::above") {
        let bound = parse_bound::<O::Output>(argument, identifier)?;
        return Ok(Condition::new::<O::Output, _>(
            crate::domain::eval::Above::new(bound),
        ));
    }
    if let Some(argument) = parenthesized(identifier, "threshold::below") {
        let bound = parse_bound::<O::Output>(argument, identifier)?;
        return Ok(Condition::new::<O::Output, _>(
            crate::domain::eval::Below::new(bound),
        ));
    }
    make_condition::<O>(identifier)
}

/// The argument inside `prefix(...)`, or `None` when `identifier` is not that.
fn parenthesized<'a>(identifier: &'a str, prefix: &str) -> Option<&'a str> {
    identifier
        .strip_prefix(prefix)?
        .strip_prefix('(')?
        .strip_suffix(')')
}

/// Parse a threshold argument as `T`, reporting the whole identifier on failure.
///
/// The `FromStr` error is carried rather than dropped. `ParseIntError`'s own
/// text is "invalid digit found in string" or "cannot parse integer from empty
/// string", which says what the standard library saw and nothing about the
/// condition the caller has to repair — but it is the part that distinguishes
/// an empty threshold from a malformed one, so it rides along beside
/// `identifier`.
fn parse_bound<T>(argument: &str, identifier: &str) -> Result<T, BotError>
where
    T: std::str::FromStr,
    // The parse failure is carried into the error, so the error type has to be
    // renderable. Every std numeric and bool parser meets this; a `FromStr`
    // whose `Err` cannot print would be refusing in a way this error cannot
    // report, which is why the bound is here rather than the field being
    // dropped.
    T::Err: std::fmt::Display,
{
    argument
        .trim()
        .parse::<T>()
        .map_err(|not_this_type| BotError::UnknownCondition {
            condition: identifier.to_owned(),
            argument_parse: not_this_type.to_string(),
        })
}

impl std::fmt::Debug for Source {
    /// Names the domain, not the source.
    ///
    /// The concrete type is erased and has no rendering of its own, so two
    /// sources of the same domain print alike. That is the honest answer rather
    /// than a gap: the erased value's contents are not part of this handle's
    /// contract, and a caller that needs them wants the domain's own accessor.
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter
            .debug_tuple("Source")
            .field(&self.domain_id())
            .finish()
    }
}

/// An action, erased to the view the runner calls.
pub struct Action(Box<dyn ExecuteAny>);

impl Action {
    /// Erase a concrete action into the handle a registry entry returns.
    #[must_use]
    pub fn new<A>(action: A) -> Self
    where
        A: Execute + 'static,
        A::Input: 'static,
        A::Output: 'static,
    {
        Self(Box::new(TypedExec::new(action)))
    }

    /// The domain identifier the action declares for itself.
    #[must_use]
    pub fn domain_id(&self) -> &str {
        self.0.domain_id()
    }

    /// The capabilities this action requires, for admission.
    #[must_use]
    pub fn required_caps(&self) -> &[Cap] {
        self.0.required_caps()
    }

    /// Take the erased action, for the chain assembler.
    pub(crate) fn into_execute_any(self) -> Box<dyn ExecuteAny> {
        self.0
    }
}

impl std::fmt::Debug for Action {
    /// Names the domain, not the action, for the reason [`Source`]'s does.
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter
            .debug_tuple("Action")
            .field(&self.domain_id())
            .finish()
    }
}

/// A condition, erased to the view a chain's walk calls.
///
/// A spec names a condition as text and [`Source::condition`] builds one for its
/// own output type; a native chain names one directly through
/// [`ObserveBuilder::on`](crate::spec::ObserveBuilder::on). Both arrive here, so
/// the two paths share one erasure and one type-mismatch report rather than each
/// carrying its own.
///
/// This is the *only* way a condition is built for a materialized chain. The
/// constructor is generic over the value it evaluates — as the verb traits are —
/// and the downcast to that value is checked when the condition runs, so a
/// condition built for one type and handed a value of another reports
/// [`BotError::EvaluateError`] rather than
/// answering a false.
pub struct Condition(Box<dyn EvaluateAny>);

impl Condition {
    /// Erase a concrete condition, typed against `T`.
    #[must_use]
    pub fn new<T, C>(condition: C) -> Self
    where
        T: 'static,
        C: Evaluate<T> + 'static,
    {
        Self(Box::new(TypedEval {
            inner: condition,
            _marker: std::marker::PhantomData::<T>,
        }))
    }

    /// Take the erased condition, for the chain assembler.
    pub(crate) fn into_evaluate_any(self) -> Box<dyn EvaluateAny> {
        self.0
    }
}

impl std::fmt::Debug for Condition {
    /// Prints `Condition`, because an erased condition carries no identity of
    /// its own: what it evaluates is the source's output type, which the chain,
    /// not this handle, is what names.
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter.write_str("Condition")
    }
}

/// Every domain a spec may name, and what each identifier builds.
///
/// Built by [`domains!`](crate::domains), which is the only entry point:
///
/// ```rust
/// use lgwks_bot::{Auth, BotError, Cap, Observe, Source, domains};
///
/// /// A source that reports an open pull request count.
/// struct GithubPrStatus;
///
/// impl GithubPrStatus {
///     /// Build one from the `target` its spec names.
///     fn from_target(target: &str) -> Result<Source, BotError> {
///         let _ = target;
///         Ok(Source::new(Self))
///     }
/// }
///
/// impl Observe for GithubPrStatus {
///     type Output = u16;
///     fn required_caps(&self) -> &[Cap] { &[] }
///     async fn poll(&self, call: (Auth, ())) -> Result<u16, BotError> {
///         call.0.check(&[])?;
///         Ok(0)
///     }
///     fn domain_id(&self) -> &str { "github::pr_status" }
/// }
///
/// domains! {
///     /// The domains this bot can run.
///     pub DOMAINS {
///         observe {
///             "github::pr_status" => GithubPrStatus::from_target,
///         }
///         execute {}
///     }
/// }
///
/// assert!(DOMAINS.source("github::pr_status").is_some());
/// # Ok::<(), BotError>(())
/// ```
///
/// The two lists are separate because a source and an action are constructed
/// from the same `&str` but produce different erased traits, and one identifier
/// may legitimately name both — a domain that observes a repository and also
/// acts on one is one domain with two roles, not a name collision.
pub struct DomainRegistry {
    /// Registered sources, in declaration order.
    sources: &'static [(&'static str, SourceCtor)],
    /// Registered actions, in declaration order.
    actions: &'static [(&'static str, ActionCtor)],
}

impl DomainRegistry {
    /// Assemble a registry from the two declaration lists.
    #[must_use]
    pub const fn new(
        sources: &'static [(&'static str, SourceCtor)],
        actions: &'static [(&'static str, ActionCtor)],
    ) -> Self {
        Self { sources, actions }
    }

    /// A registry with nothing registered.
    ///
    /// Useful as the identity for a composition, and as the value a test builds
    /// when it wants to assert that an identifier is *not* known.
    #[must_use]
    pub const fn empty() -> Self {
        Self {
            sources: &[],
            actions: &[],
        }
    }

    /// The constructor registered for a source identifier, if any.
    ///
    /// `None` when the identifier is unknown *or* declared twice in the source
    /// list. An ambiguous identifier is deliberately not resolved to the first
    /// declaration: doing so would make which constructor a spec reaches depend
    /// on declaration order. [`Self::validate`] distinguishes an ambiguity from
    /// an absence by name.
    #[must_use]
    pub fn source(&self, domain_id: &str) -> Option<SourceCtor> {
        find(self.sources, domain_id)
    }

    /// The constructor registered for an action identifier, if any.
    ///
    /// `None` when the identifier is unknown *or* declared twice in the action
    /// list, for the reason [`Self::source`] gives.
    #[must_use]
    pub fn action(&self, domain_id: &str) -> Option<ActionCtor> {
        find(self.actions, domain_id)
    }

    /// Refuse a registry that declares one identifier twice within a role.
    ///
    /// Admission is fallible rather than a `const` assertion because the
    /// workspace forbids panics, and it lives on the registry rather than the
    /// macro so a hand-assembled [`DomainRegistry::new`] call is checked the
    /// same way as a `domains!` declaration. The two roles are checked
    /// independently: one identifier appearing once as a source and once as an
    /// action is one domain with two roles, not a duplicate. When a list does
    /// hold a pair, the first one is named with both positions; a caller
    /// repairs it and revalidates.
    ///
    /// Every build goes through this check, so a broken registry refuses all
    /// construction — the alternative, letting the first declaration win,
    /// would make dispatch depend on declaration order.
    pub fn validate(&self) -> Result<(), BotError> {
        if let Some((first, second)) = first_duplicate(self.sources) {
            let refusal = Err(BotError::DuplicateDomain {
                domain: self.sources[first].0.to_owned(),
                role: "source",
                first,
                second,
            });
            lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "validate: returning an error to the caller");
            return refusal;
        }
        if let Some((first, second)) = first_duplicate(self.actions) {
            let refusal = Err(BotError::DuplicateDomain {
                domain: self.actions[first].0.to_owned(),
                role: "action",
                first,
                second,
            });
            lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "validate: returning an error to the caller");
            return refusal;
        }
        Ok(())
    }

    /// Build the source a spec names, or refuse with the identifier.
    ///
    /// The refusal is typed rather than an `Option` because reaching it means
    /// the spec named a domain this binary cannot run, and a caller that has to
    /// report that should not also have to invent the wording.
    pub fn build_source(&self, domain_id: &str, target: &str) -> Result<Source, BotError> {
        self.validate()?;
        match self.source(domain_id) {
            Some(ctor) => ctor(target),
            None => Err(BotError::UnregisteredDomain {
                domain: domain_id.to_owned(),
            }),
        }
    }

    /// Build the action a spec names, or refuse with the identifier.
    pub fn build_action(&self, domain_id: &str, target: &str) -> Result<Action, BotError> {
        self.validate()?;
        match self.action(domain_id) {
            Some(ctor) => ctor(target),
            None => Err(BotError::UnregisteredDomain {
                domain: domain_id.to_owned(),
            }),
        }
    }

    /// The source identifiers this registry knows, in declaration order.
    pub fn source_ids(&self) -> impl Iterator<Item = &'static str> + '_ {
        self.sources.iter().map(|&(domain_id, _)| domain_id)
    }

    /// The action identifiers this registry knows, in declaration order.
    pub fn action_ids(&self) -> impl Iterator<Item = &'static str> + '_ {
        self.actions.iter().map(|&(domain_id, _)| domain_id)
    }
}

impl std::fmt::Debug for DomainRegistry {
    /// Lists the identifiers rather than the function pointers, which have no
    /// useful rendering and would make two registries with the same domains
    /// print differently.
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter
            .debug_struct("DomainRegistry")
            .field("sources", &self.source_ids().collect::<Vec<&str>>())
            .field("actions", &self.action_ids().collect::<Vec<&str>>())
            .finish()
    }
}

/// Find an identifier in a declaration list, in declaration order.
///
/// `None` when the identifier is absent *or* declared more than once. The
/// second case is the point: an ambiguous identifier must not resolve to
/// whichever constructor the author happened to list first, because that makes
/// dispatch depend on declaration order (issue #122). [`DomainRegistry::validate`]
/// names the duplicate pair before any build, and refusing it here means a
/// caller that reaches for `source`/`action` directly still cannot construct
/// from an ambiguous list.
///
/// Linear because the list is a handful of entries and is read at most once per
/// spec, not per tick. A map would be a second structure to keep in step with
/// the first for no gain at this size.
fn find<T: Copy>(entries: &[(&'static str, T)], domain_id: &str) -> Option<T> {
    let mut found: Option<T> = None;
    for &(registered, ctor) in entries {
        if registered == domain_id {
            if found.is_some() {
                return None;
            }
            found = Some(ctor);
        }
    }
    found
}

/// The positions of the first identifier declared twice in one list, or `None`
/// when the list is distinct. Quadratic in a list of a handful of entries —
/// the check runs once per build, not per tick — and written with comparisons
/// rather than index arithmetic, which the workspace lints forbid.
fn first_duplicate<T: PartialEq>(entries: &[(&'static str, T)]) -> Option<(usize, usize)> {
    for (first, &(registered, _)) in entries.iter().enumerate() {
        for (second, &(other, _)) in entries.iter().enumerate() {
            if second > first && other == registered {
                return Some((first, second));
            }
        }
    }
    None
}

/// Declare the domains a binary can run, in one place.
///
/// The one entry point for the `domain_id -> constructor` mapping. A worked
/// example is in [`DomainRegistry`]'s documentation.
///
/// The two halves are separate because a source and an action are built into
/// different erased traits. An empty half is written `{}` and is not an error: a
/// bot that only observes is a bot.
///
/// A constructor is any path with the shape of [`SourceCtor`] or [`ActionCtor`],
/// so it is usually an inherent function on the adapter that parses the spec's
/// `target` into the adapter's own fields.
#[macro_export]
macro_rules! domains {
    (
        $(#[$meta:meta])*
        $vis:vis $name:ident {
            observe { $( $source_id:literal => $source_ctor:path ),* $(,)? }
            execute { $( $action_id:literal => $action_ctor:path ),* $(,)? }
        }
    ) => {
        $(#[$meta])*
        $vis static $name: $crate::DomainRegistry = $crate::DomainRegistry::new(
            &[ $( ($source_id, $source_ctor as $crate::SourceCtor) ),* ],
            &[ $( ($action_id, $action_ctor as $crate::ActionCtor) ),* ],
        );
    };
}